pax_global_header00006660000000000000000000000064150061211040014500gustar00rootroot0000000000000052 comment=8582ed2074e310fbbb375054e60f70487f67ee76 lazygit-0.50.0+ds1/000077500000000000000000000000001500612110400137305ustar00rootroot00000000000000lazygit-0.50.0+ds1/.codespellrc000066400000000000000000000004541500612110400162330ustar00rootroot00000000000000[codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file skip = .git*,go.sum,*.lock,.codespellrc,vendor,translations,Keybindings_*.md check-hidden = true # camel-cased ignore-regex = (\b[A-Za-z][a-z]*[A-Z]\S+\b|\.edn\b|\S+…|\\nd\b) ignore-words-list = fomrat,inbetween lazygit-0.50.0+ds1/.devcontainer/000077500000000000000000000000001500612110400164675ustar00rootroot00000000000000lazygit-0.50.0+ds1/.devcontainer/Dockerfile000066400000000000000000000013271500612110400204640ustar00rootroot00000000000000# 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 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 \ # && apt-get -y install --no-install-recommends lazygit-0.50.0+ds1/.devcontainer/devcontainer.json000066400000000000000000000046471500612110400220560ustar00rootroot00000000000000// adapted from https://github.com/devcontainers/images/blob/main/src/go/.devcontainer/devcontainer.json { "build": { "dockerfile": "./Dockerfile", "context": "." }, "features": { "ghcr.io/devcontainers/features/common-utils:1": { "installZsh": "true", "username": "vscode", "uid": "1000", "gid": "1000", "upgradePackages": "true" }, "ghcr.io/devcontainers/features/go:1": { "version": "none" }, "ghcr.io/devcontainers/features/git:1": { "version": "latest", "ppa": "false" } }, "overrideFeatureInstallOrder": [ "ghcr.io/devcontainers/features/common-utils" ], // not sure if we actually need these "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], // Configure tool-specific properties. "customizations": { // Configure properties specific to VS Code. "vscode": { // Set *default* container specific settings.json values on container create. "settings": { "go.toolsManagement.checkForUpdates": "local", "go.useLanguageServer": true, "go.gopath": "/go", "[go]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": true } }, "go.lintTool": "golangci-lint", "gopls": { "formatting.gofumpt": true, "usePlaceholders": false // add parameter placeholders when completing a function }, "files.eol": "\n" }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ "golang.Go" ] } }, // Use 'postCreateCommand' to run commands after the container is created. // "postCreateCommand": "go version", // See https://www.kenmuse.com/blog/avoiding-dubious-ownership-in-dev-containers/ for the safe.directory part // The defaultBranch part is required for our deprecated integration tests. "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && git config --global init.defaultBranch master", // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode" } lazygit-0.50.0+ds1/.editorconfig000066400000000000000000000000471500612110400164060ustar00rootroot00000000000000root = true [*.go] indent_style = tab lazygit-0.50.0+ds1/.gitattributes000066400000000000000000000000651500612110400166240ustar00rootroot00000000000000*.go text *.md text eol=lf *.json text eol=lf lazygit-0.50.0+ds1/.github/000077500000000000000000000000001500612110400152705ustar00rootroot00000000000000lazygit-0.50.0+ds1/.github/FUNDING.yml000066400000000000000000000001601500612110400171020ustar00rootroot00000000000000# These are supported funding model platforms github: [jesseduffield] custom: ['https://donorbox.org/lazygit'] lazygit-0.50.0+ds1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001500612110400174535ustar00rootroot00000000000000lazygit-0.50.0+ds1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000017451500612110400221540ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **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_ **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. lazygit-0.50.0+ds1/.github/ISSUE_TEMPLATE/discussion.md000066400000000000000000000003361500612110400221620ustar00rootroot00000000000000--- name: Discussion about: Begin a discussion title: '' labels: discussion assignees: '' --- **Topic** A clear and concise description of what you want to discuss **Your thoughts** What you have to say about the topic lazygit-0.50.0+ds1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000025371500612110400232070ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. lazygit-0.50.0+ds1/.github/dependabot.yml000066400000000000000000000002631500612110400201210ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" allowed_updates: - match: update_type: "security" lazygit-0.50.0+ds1/.github/pull_request_template.md000066400000000000000000000017761500612110400222440ustar00rootroot00000000000000- **PR Description** - **Please check if the PR fulfills these requirements** * [ ] 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)) * [ ] 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 lazygit-0.50.0+ds1/.github/release.yml000066400000000000000000000010561500612110400174350ustar00rootroot00000000000000changelog: exclude: labels: - ignore-for-release categories: - title: Features ✨ labels: - feature - title: Enhancements 🔥 labels: - enhancement - title: Fixes 🔧 labels: - bug - title: Maintenance ⚙️ labels: - maintenance - title: Docs 📖 labels: - docs - title: I18n 🌎 labels: - i18n - title: Performance Improvements 📊 labels: - performance - title: Other Changes labels: - "*" lazygit-0.50.0+ds1/.github/workflows/000077500000000000000000000000001500612110400173255ustar00rootroot00000000000000lazygit-0.50.0+ds1/.github/workflows/ci.yml000066400000000000000000000201701500612110400204430ustar00rootroot00000000000000name: Continuous Integration env: GO_VERSION: 1.24 on: push: branches: - master pull_request: jobs: unit-tests: strategy: fail-fast: false matrix: os: - ubuntu-latest - windows-latest include: - os: ubuntu-latest cache_path: ~/.cache/go-build - os: windows-latest cache_path: ~\AppData\Local\go-build name: ci - ${{matrix.os}} runs-on: ${{matrix.os}} env: GOFLAGS: -mod=vendor steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.24.x - name: Test code # we're passing -short so that we skip the integration tests, which will be run in parallel below run: | mkdir -p /tmp/code_coverage go test ./... -short -cover -args "-test.gocoverdir=/tmp/code_coverage" - name: Upload code coverage artifacts uses: actions/upload-artifact@v4 with: name: coverage-unit-${{ matrix.os }}-${{ github.run_id }} path: /tmp/code_coverage integration-tests: strategy: fail-fast: false matrix: git-version: - 2.22.0 # oldest supported version - 2.23.0 - 2.25.1 - 2.30.8 - latest # We rely on github to have the latest version installed on their VMs runs-on: ubuntu-latest name: "Integration Tests - git ${{matrix.git-version}}" env: GOFLAGS: -mod=vendor steps: - name: Checkout code uses: actions/checkout@v4 - name: Restore Git cache if: matrix.git-version != 'latest' id: cache-git-restore uses: actions/cache/restore@v4 with: path: ~/git-${{matrix.git-version}} key: ${{runner.os}}-git-${{matrix.git-version}} - name: Build Git ${{matrix.git-version}} if: steps.cache-git-restore.outputs.cache-hit != 'true' && matrix.git-version != 'latest' run: > sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential ca-certificates curl gettext libexpat1-dev libssl-dev libz-dev openssl && curl -sL "https://mirrors.edge.kernel.org/pub/software/scm/git/git-${{matrix.git-version}}.tar.xz" -o - | tar xJ -C "$HOME" && cd "$HOME/git-${{matrix.git-version}}" && ./configure && make -j - name: Install Git ${{matrix.git-version}} if: matrix.git-version != 'latest' 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@v4 with: path: ~/git-${{matrix.git-version}} key: ${{runner.os}}-git-${{matrix.git-version}} - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.24.x - name: Print git version run: git --version - name: Test code env: # See https://go.dev/blog/integration-test-coverage LAZYGIT_GOCOVERDIR: /tmp/code_coverage run: | mkdir -p /tmp/code_coverage ./scripts/run_integration_tests.sh - name: Upload code coverage artifacts uses: actions/upload-artifact@v4 with: name: coverage-integration-${{ matrix.git-version }}-${{ github.run_id }} path: /tmp/code_coverage build: runs-on: ubuntu-latest env: GOFLAGS: -mod=vendor GOARCH: amd64 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.24.x - name: Build linux binary run: | GOOS=linux go build - name: Build windows binary run: | GOOS=windows go build - name: Build darwin binary run: | GOOS=darwin go build - name: Build integration test binary run: | GOOS=linux go build cmd/integration_test/main.go - name: Build integration test injector run: | GOOS=linux go build pkg/integration/clients/injector/main.go check-codebase: runs-on: ubuntu-latest env: GOFLAGS: -mod=vendor GOARCH: amd64 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.24.x - 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 go.mod file # ensure our go.mod file is clean run: | 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 lint: runs-on: ubuntu-latest env: GOFLAGS: -mod=vendor steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.24.x - name: Lint uses: golangci/golangci-lint-action@v6.5.0 with: version: v1.64.6 - name: errors run: golangci-lint run if: ${{ failure() }} check-required-label: runs-on: ubuntu-latest if: github.ref != 'refs/heads/master' steps: - uses: mheap/github-action-required-labels@v5 with: mode: exactly count: 1 labels: "ignore-for-release, feature, enhancement, bug, maintenance, docs, i18n, performance" upload-coverage: # List all jobs that produce coverage files needs: [unit-tests, integration-tests] if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.24.x - name: Download all coverage artifacts uses: actions/download-artifact@v4 with: path: /tmp/code_coverage - name: Combine coverage files run: | # Find all directories in /tmp/code_coverage and create a comma-separated list COVERAGE_DIRS=$(find /tmp/code_coverage -mindepth 1 -maxdepth 1 -type d -printf '/tmp/code_coverage/%f,' | sed 's/,$//') echo "Coverage directories: $COVERAGE_DIRS" # Run the combine command with the generated list go tool covdata textfmt -i=$COVERAGE_DIRS -o coverage.out echo "Combined coverage:" go tool cover -func coverage.out | tail -1 | awk '{print $3}' - name: Upload to Codacy run: | CODACY_PROJECT_TOKEN=${{ secrets.CODACY_PROJECT_TOKEN }} \ bash <(curl -Ls https://coverage.codacy.com/get.sh) report \ --force-coverage-parser go -r coverage.out check-for-fixups: runs-on: ubuntu-latest if: github.ref != 'refs/heads/master' steps: # 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@v4 with: 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 }} lazygit-0.50.0+ds1/.github/workflows/close-issues.yml000066400000000000000000000021151500612110400224650ustar00rootroot00000000000000name: 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@v7 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}.`) lazygit-0.50.0+ds1/.github/workflows/codespell.yml000066400000000000000000000007751500612110400220330ustar00rootroot00000000000000# Codespell configuration is within .codespellrc --- name: Codespell on: push: branches: [master] pull_request: branches: [master] permissions: contents: read jobs: codespell: name: Check for spelling errors runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@v1 - name: Codespell uses: codespell-project/actions-codespell@v2 lazygit-0.50.0+ds1/.github/workflows/release.yml000066400000000000000000000104671500612110400215000ustar00rootroot00000000000000name: Release on: schedule: # Runs at 2: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 2 * * 6' # Allow manual triggering of the workflow workflow_dispatch: inputs: version_bump: description: 'Version bump type' type: choice required: true default: 'patch' options: - minor - patch ignore_blocks: description: 'Ignore blocking PRs/issues' type: boolean required: true default: false jobs: check-and-release: runs-on: ubuntu-latest steps: - 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@v4 with: fetch-depth: 0 - name: Get Latest Tag run: | latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) || 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 run: | if [ -z "$(git diff --name-only ${{ env.latest_tag }})" ]; then echo "No changes detected since last release" 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.GITHUB_TOKEN }} - name: Calculate next version run: | echo "Latest tag: ${{ env.latest_tag }}" IFS='.' read -r major minor patch <<< "${{ env.latest_tag }}" if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then if [[ "${{ inputs.version_bump }}" == "patch" ]]; 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: Create and Push Tag run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag ${{ env.new_tag }} git push origin ${{ env.new_tag }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_API_TOKEN }} - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.24.x - name: Run goreleaser uses: goreleaser/goreleaser-action@v4 with: distribution: goreleaser version: v1.17.2 args: release --clean env: GITHUB_TOKEN: ${{secrets.GITHUB_API_TOKEN}} - name: Bump Homebrew formula uses: dawidd6/action-homebrew-bump-formula@v3 with: token: ${{secrets.GITHUB_API_TOKEN}} formula: lazygit tag: ${{env.new_tag}} lazygit-0.50.0+ds1/.github/workflows/sponsors.yml000066400000000000000000000015551500612110400217440ustar00rootroot00000000000000# see https://github.com/JamesIves/github-sponsors-readme-action name: Generate Sponsors README on: push: branches: - master jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ uses: actions/checkout@v4 - name: Generate Sponsors 💖 uses: JamesIves/github-sponsors-readme-action@v1.2.2 with: token: ${{ secrets.SPONSORS_TOKEN }} file: "README.md" if: ${{ github.repository == 'jesseduffield/lazygit' }} - name: Create Pull Request 🚀 uses: peter-evans/create-pull-request@v6 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 lazygit-0.50.0+ds1/.gitignore000066400000000000000000000007161500612110400157240ustar00rootroot00000000000000# Please do not add personal files # Logs *.log # Hidden .* !.codespellrc # Notes *.notes # Tests test/repos/repo coverage.txt # Binaries lazygit lazygit.exe # Exceptions !.gitignore !.gitattributes !.goreleaser.yml !.golangci.yml !.circleci/ !.github/ !.vscode/ !.devcontainer/ # these are for our integration tests !.git_keep !.gitmodules_keep test/git_server/data test/_results/** oryxBuildBinary __debug_bin .worktrees demo/output/* coverage.out lazygit-0.50.0+ds1/.golangci.yml000066400000000000000000000015571500612110400163240ustar00rootroot00000000000000linters: enable: - gofumpt - thelper - goimports - tparallel - wastedassign - unparam - prealloc - unconvert - exhaustive - makezero - nakedret - copyloopvar fast: false linters-settings: copyloopvar: # Check all assigning the loop variable to another variable. # Default: false # If true, an assignment like `a := x` will be detected as an error. check-alias: true exhaustive: default-signifies-exhaustive: true staticcheck: # 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. checks: ["all", "-SA1019"] nakedret: # the gods will judge me but I just don't like naked returns at all max-func-lines: 0 run: go: "1.24" timeout: 10m lazygit-0.50.0+ds1/.goreleaser.yml000066400000000000000000000026071500612110400166660ustar00rootroot00000000000000# This is an example goreleaser.yaml file with some sane defaults. # Make sure to check the documentation at http://goreleaser.com builds: - env: - CGO_ENABLED=0 goos: - freebsd - windows - darwin - linux goarch: - amd64 - arm - arm64 - '386' # Default is `-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}`. ldflags: - -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 format_overrides: - goos: windows format: zip checksum: name_template: 'checksums.txt' snapshot: name_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 lazygit-0.50.0+ds1/.vscode/000077500000000000000000000000001500612110400152715ustar00rootroot00000000000000lazygit-0.50.0+ds1/.vscode/debugger_config.yml000066400000000000000000000000331500612110400211210ustar00rootroot00000000000000disableStartupPopups: true lazygit-0.50.0+ds1/.vscode/launch.json000066400000000000000000000034031500612110400174360ustar00rootroot00000000000000{ "version": "0.2.0", "configurations": [ { "name": "Debug Lazygit", "type": "go", "request": "launch", "mode": "auto", "program": "main.go", "args": [ "--debug", "--use-config-file=${workspaceFolder}/.vscode/debugger_config.yml" ], "hideSystemGoroutines": true, "console": "integratedTerminal", }, { "name": "Tail Lazygit logs", "type": "go", "request": "launch", "mode": "auto", "program": "main.go", "args": [ "--logs", "--use-config-file=${workspaceFolder}/.vscode/debugger_config.yml" ], "console": "integratedTerminal", }, { "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" ], "stopAll": true } ] } lazygit-0.50.0+ds1/.vscode/settings.json000066400000000000000000000000661500612110400200260ustar00rootroot00000000000000{ "gopls": { "formatting.gofumpt": true, }, } lazygit-0.50.0+ds1/.vscode/tasks.json000066400000000000000000000042701500612110400173140ustar00rootroot00000000000000{ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { "label": "Generate cheatsheet", "type": "shell", "command": "go run scripts/cheatsheet/main.go generate", "problemMatcher": [], }, { "label": "Bump gocui", "type": "shell", "command": "./scripts/bump_gocui.sh", "problemMatcher": [], }, { "label": "Bump lazycore", "type": "shell", "command": "./scripts/bump_lazycore.sh", "problemMatcher": [], }, { "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}", "problemMatcher": [], "group": { "kind": "test", "isDefault": true }, "presentation": { "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}", "problemMatcher": [], "group": { "kind": "test", }, "presentation": { "focus": true } }, { "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", "problemMatcher": [], "group": { "kind": "test", }, "presentation": { "focus": true } }, { "label": "Sync tests list", "type": "shell", "command": "go generate pkg/integration/tests/tests.go", "problemMatcher": [], "group": { "kind": "test", }, "presentation": { "focus": true } }, ], } lazygit-0.50.0+ds1/CODE-OF-CONDUCT.md000066400000000000000000000001111500612110400163540ustar00rootroot00000000000000# Lazygit Code of Conduct Be nice, or face the wrath of the maintainer. lazygit-0.50.0+ds1/CONTRIBUTING.md000066400000000000000000000232531500612110400161660ustar00rootroot00000000000000# Contributing ♥ We love pull requests from everyone ! 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. ## PR walkthrough [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. ## Design principles See [here](./VISION.md) for a set of design principles that we want to consider when building a feature or making a change. ## Codebase guide [This doc](./docs/dev/Codebase_Guide.md) explains: * what the different packages in the codebase are for * where important files live * important concepts in the code * how the event loop works * other useful information ## All code changes happen through Pull Requests Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests: 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! Please do not raise pull request from your fork's master branch: make a feature branch instead. Lazygit maintainers will sometimes push changes to your branch when reviewing a PR and we often can't do this if you use your master branch. 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. ## Running in a VSCode dev container 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: ![image](https://user-images.githubusercontent.com/8456633/201500508-0d55f99f-5035-4a6f-a0f8-eaea5c003e5d.png) This requires that: * you have docker installed * you have the dev containers extension installed in VSCode See [here](https://code.visualstudio.com/docs/devcontainers/containers) for more info about dev containers. ## 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: ![image](https://user-images.githubusercontent.com/8456633/201500566-ffe9105d-6030-4cc7-a525-6570b0b413a2.png) 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 . ``` ## Programming Font Lazygit supports [Nerd Fonts](https://www.nerdfonts.com) to render certain icons. Sometimes we use some of these icons verbatim in string literals in the code (mainly in tests), so you need to set your development environment to use a nerd font to see these. ## 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 or memory usage, see [this separate document](docs/dev/Profiling.md). ## 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. lazygit-0.50.0+ds1/Dockerfile000066400000000000000000000010551500612110400157230ustar00rootroot00000000000000# run with: # docker build -t lazygit . # docker run -it lazygit:latest /bin/sh FROM golang:1.24 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.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 ./ COPY --from=build /go/src/github.com/jesseduffield/lazygit/lazygit /bin/ RUN echo "alias gg=lazygit" >> ~/.profile ENTRYPOINT [ "lazygit" ] lazygit-0.50.0+ds1/LICENSE000066400000000000000000000020571500612110400147410ustar00rootroot00000000000000MIT License Copyright (c) 2018 Jesse Duffield 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. lazygit-0.50.0+ds1/Makefile000066400000000000000000000026411500612110400153730ustar00rootroot00000000000000.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: gofumpt -l -w . .PHONY: lint lint: golangci-lint 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: vendor vendor: go mod vendor && go mod tidy lazygit-0.50.0+ds1/README.md000066400000000000000000000760151500612110400152200ustar00rootroot00000000000000
Special thanks to:

Warp
Warp, the intelligent terminal
Available for MacOS and Linux
Visit warp.dev to learn more.


Subble
I (Jesse) co-founded Subble to save your company time and money by helping you manage its software subscriptions. Check it out!


A simple terminal UI for git commands
[![GitHub Releases](https://img.shields.io/github/downloads/jesseduffield/lazygit/total)](https://github.com/jesseduffield/lazygit/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/jesseduffield/lazygit)](https://goreportcard.com/report/github.com/jesseduffield/lazygit) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/f46416b715d74622895657935fcada21)](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/f46416b715d74622895657935fcada21)](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) [![GolangCI](https://golangci.com/badges/github.com/jesseduffield/lazygit.svg)](https://golangci.com) [![GitHub tag](https://img.shields.io/github/tag/jesseduffield/lazygit.svg)](https://github.com/jesseduffield/lazygit/releases/latest) [![homebrew](https://img.shields.io/homebrew/v/lazygit)](https://github.com/Homebrew/homebrew-core/blob/master/Formula/lazygit.rb) ![commit_and_push](../assets/demo/commit_and_push-compressed.gif)
## Sponsors

Maintenance of this project is made possible by all the contributors and sponsors. If you'd like to sponsor this project and have your avatar or company logo appear below click here. 💙

Mark LussierDean HerbertPeter BjorklundReilly WoodOliver GüntherPawan DhananjayBartłomiej DachCarsten GehlingCEUKHolden LucasChau TranmatejciktheAverageDev (Luca Tumedei)Piotr KowalskiNicholas CloudAliaksandr StelmachonakBurgy BenjaminJoe KlemmerTobias LütkeBen BeaumontHollyCasey BoettcherJeff ForcierMaciej T. NowakAndreas KurthBraden SteffaniakJordan GillardSebastianGeorge SpanosAndy SlezakMartin KockJesse AlamaDaniel KokottJan HeijmansKevin Nowaldsem pruijsOmar LuqEthan LiBrian MacAskillMaxiJan ZenknerVictor AremuJJFrederick MorlockMaximilian LangenfeldDavis BulsNeil LambertDavid Heinemeier HanssonMarco Aurelio Caldas MirandaEmmanuel NosakhareEthan FischerTerry TaiAdam RoesnerTim MorganMax ShypulniakKovács ÁdámtimbryandevSviatoslav AbakumovPatricio SerranoKiriBob ParsonsJohn Even BjørnevikMichael OberstStian HegglundKenth FagerlundJulien TardotAaron ArredondoEllord TayagEdgar Post-Buijssbc64Pierre SpringZac ClayThomas MüllerCarl AssmannSergey OgnevAlex GMichael HowardLasse Bloch LauritsenLarry MarburgerDavid BrockmanAlexander SlavschikAidan GaulandMaksym BieńkowskiRoman ZippJoshua WootonnJoe BuzaAnton Kesy

## Elevator Pitch Rant time: You've heard it before, git is _powerful_, but what good is that power when everything is so damn hard to do? Interactive rebasing requires you to edit a goddamn TODO file in your editor? _Are you kidding me?_ To stage part of a file you need to use a command line program to step through each hunk and if a hunk can't be split down any further but contains code you don't want to stage, you have to edit an arcane patch file _by hand_? _Are you KIDDING me?!_ Sometimes you get asked to stash your changes when switching branches only to realise that after you switch and unstash that there weren't even any conflicts and it would have been fine to just checkout the branch directly? _YOU HAVE GOT TO BE KIDDING ME!_ If you're a mere mortal like me and you're tired of hearing how powerful git is when in your daily life it's a powerful pain in your ass, lazygit might be for you. ## Table of contents - [Sponsors](#sponsors) - [Elevator Pitch](#elevator-pitch) - [Table of contents](#table-of-contents) - [Features](#features) - [Stage individual lines](#stage-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) - [Homebrew](#homebrew) - [MacPorts](#macports) - [Void Linux](#void-linux) - [Scoop (Windows)](#scoop-windows) - [Arch Linux](#arch-linux) - [Fedora and RHEL](#fedora-and-rhel) - [Solus Linux](#solus-linux) - [Debian and Ubuntu](#debian-and-ubuntu) - [Funtoo Linux](#funtoo-linux) - [Gentoo Linux](#gentoo-linux) - [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) - [Undo/Redo](#undoredo) - [Configuration](#configuration) - [Custom Pagers](#custom-pagers) - [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 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. ![stage_lines](../assets/demo/stage_lines-compressed.gif) ### Interactive 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 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. This demo also uses shift+down to select a range of commits to move and fixup. ![interactive_rebase](../assets/demo/interactive_rebase-compressed.gif) ### Cherry-pick Press `shift+c` on a commit to copy it and press `shift+v` to paste (cherry-pick) it. ![cherry_pick](../assets/demo/cherry_pick-compressed.gif) ### Bisect Press `b` in the commits view to mark a commit as good/bad in order to begin a git bisect. ![bisect](../assets/demo/bisect-compressed.gif) ### 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=N4E2B_k2Bss), press `shift+d` to bring up the reset options menu and then select the 'nuke' option. ![Nuke working tree](../assets/demo/nuke_working_tree-compressed.gif) ### 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 background). ![amend_old_commit](../assets/demo/amend_old_commit-compressed.gif) ### Filter You can filter a view with `/`. Here we filter down our branches view and then hit `enter` to view its commits. ![filter](../assets/demo/filter-compressed.gif) ### 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. ![custom_command](../assets/demo/custom_command-compressed.gif) ### 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. ![worktree_create_from_branches](../assets/demo/worktree_create_from_branches-compressed.gif) ### 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. Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs). ![custom_patch](../assets/demo/custom_patch-compressed.gif) ### 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`. ![rebase_onto](../assets/demo/rebase_onto-compressed.gif) ### Undo You can undo the last action by pressing 'z' and redo with `ctrl+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) ![undo](../assets/demo/undo-compressed.gif) ### 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. ![commit_graph](../assets/demo/commit_graph-compressed.gif) ### 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 ``. ![diff_commits](../assets/demo/diff_commits-compressed.gif) ## Tutorials [](https://youtu.be/CPLdltN7wgE) - [15 Lazygit Features in 15 Minutes](https://youtu.be/CPLdltN7wgE) - [Basics Tutorial](https://youtu.be/VDXvbHZYeKY) - [Rebase Magic Tutorial](https://youtu.be/4XaToVut_hs) ## Installation [![Packaging status](https://repology.org/badge/vertical-allrepos/lazygit.svg?columns=3)](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_ ### Binary Releases 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. Tap: ``` brew install jesseduffield/lazygit/lazygit ``` Core: ``` brew install lazygit ``` ### MacPorts Latest version built from github releases. Tap: ``` sudo port install lazygit ``` ### Void Linux Packages for Void Linux are available in the distro repo They follow upstream latest releases ```sh sudo xbps-install -S lazygit ``` ### Scoop (Windows) You can install `lazygit` using [scoop](https://scoop.sh/). It's in the `extras` bucket: ```sh # Add the extras bucket scoop bucket add extras # Install lazygit scoop install lazygit ``` ### Arch Linux Packages for Arch Linux are available via pacman and AUR (Arch User Repository). There are two packages. The stable one which is built with the latest release and the git version which builds from the most recent commit. - Stable: `sudo pacman -S lazygit` - Development: Instruction of how to install AUR content can be found here: ### Fedora and RHEL Packages for Fedora/RHEL and CentOS Stream are available via [Copr](https://copr.fedorainfracloud.org/coprs/atim/lazygit/) (Cool Other Package Repo). ```sh sudo dnf copr enable atim/lazygit -y sudo dnf install lazygit ``` ### Solus Linux ```sh sudo eopkg install lazygit ``` ### Debian and Ubuntu For **Debian 13 "Trixie", Sid**, and later, or **Ubuntu 25.10 "Questing Quokka"** and later: ```sh 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[^"]*') curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" tar xf lazygit.tar.gz lazygit sudo install lazygit -D -t /usr/local/bin/ ``` Verify the correct installation of lazygit: ```sh lazygit --version ``` ### Funtoo Linux Funtoo Linux has an autogenerated lazygit package in [dev-kit](https://github.com/funtoo/dev-kit/tree/1.4-release/dev-vcs/lazygit): ```sh sudo emerge dev-vcs/lazygit ``` ### Gentoo Linux 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 guru sudo emaint sync -r guru sudo emerge dev-vcs/lazygit ``` ### 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 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 On NixOs lazygit is packaged with nix and distributed via nixpkgs. You can try the lazygit without installing it with: ```sh nix-shell -p lazygit # or with flakes enabled nix run nixpkgs#lazygit ``` Or you can add lazygit to you `configuration.nix` using the `environment.systemPackages` option. More details can be found via NixOs search [page](https://search.nixos.org/). ### 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 ```sh conda install -c conda-forge lazygit ``` ### Go ```sh 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` (Windows). Not to be mistaken for `C:\Go\bin` (which is for Go's own binaries, not apps like lazygit). ### Chocolatey (Windows) You can install `lazygit` using [Chocolatey](https://chocolatey.org/): ```sh 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) ``` git clone https://github.com/jesseduffield/lazygit.git cd lazygit go install ``` You can also use `go run main.go` to compile and run in one go (pun definitely intended) ## Usage Call `lazygit` in your terminal inside a git repository. ```sh $ lazygit ``` If you want, you can also add an alias for this with `echo "alias lg='lazygit'" >> ~/.zshrc` (or whichever rc file you're using). ### Keybindings You can check out the list of keybindings [here](/docs/keybindings). ### Changing Directory On Exit If you change repos in lazygit and want your shell to change directory into that repo on exiting lazygit, add this to your `~/.zshrc` (or other rc file): ``` lg() { export LAZYGIT_NEW_DIR_FILE=~/.lazygit/newdir lazygit "$@" if [ -f $LAZYGIT_NEW_DIR_FILE ]; then cd "$(cat $LAZYGIT_NEW_DIR_FILE)" rm -f $LAZYGIT_NEW_DIR_FILE > /dev/null fi } ``` Then `source ~/.zshrc` and from now on when you call `lg` and exit you'll switch directories to whatever you were in inside lazygit. To override this behaviour you can exit using `shift+Q` rather than just `q`. ### Undo/Redo See the [docs](/docs/Undoing.md) ## Configuration Check out the [configuration docs](docs/Config.md). ### Custom Pagers See the [docs](docs/Custom_Pagers.md) ### Custom Commands If lazygit is missing a feature, there's a good chance you can implement it yourself with a custom command! See the [docs](docs/Custom_Command_Keybindings.md) ### Git flow support Lazygit supports [Gitflow](https://github.com/nvie/gitflow) 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). For contributor discussion about things not better discussed here in the repo, join the [discord channel](https://discord.gg/ehwFt2t4wt) Check out this [video](https://www.youtube.com/watch?v=kNavnhzZHtk) walking through the creation of a small feature in lazygit if you want an idea of where to get started. ### Debugging Locally Run `lazygit --debug` in one terminal tab and `lazygit --logs` in another to view the program and its log output side by side ## Donate If you would like to support the development of lazygit, consider [sponsoring me](https://github.com/sponsors/jesseduffield) (github is matching all donations dollar-for-dollar for 12 months) ## FAQ ### What do the commit colors represent? - Green: the commit is included in the master branch - Yellow: the commit is not included in the master branch - Red: the commit has not been pushed to the upstream branch ## Shameless Plug If you want to see what I (Jesse) am up to in terms of development, follow me on [twitter](https://twitter.com/DuffieldJesse) or check out my [blog](https://jesseduffield.com/) ## Alternatives If you find that lazygit doesn't quite satisfy your requirements, these may be a better fit: - [GitUI](https://github.com/Extrawurst/gitui) - [tig](https://github.com/jonas/tig) lazygit-0.50.0+ds1/VISION.md000066400000000000000000000116261500612110400152670ustar00rootroot00000000000000# 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: - Dicoverability - Simplicity - Safety - Power - Speed - Conformity with git - 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 ### 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. lazygit-0.50.0+ds1/cmd/000077500000000000000000000000001500612110400144735ustar00rootroot00000000000000lazygit-0.50.0+ds1/cmd/i18n/000077500000000000000000000000001500612110400152525ustar00rootroot00000000000000lazygit-0.50.0+ds1/cmd/i18n/main.go000066400000000000000000000007341500612110400165310ustar00rootroot00000000000000package 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) } } lazygit-0.50.0+ds1/cmd/integration_test/000077500000000000000000000000001500612110400200555ustar00rootroot00000000000000lazygit-0.50.0+ds1/cmd/integration_test/main.go000066400000000000000000000036731500612110400213410ustar00rootroot00000000000000package main import ( "fmt" "log" "os" "github.com/jesseduffield/lazygit/pkg/integration/clients" ) var usage = ` Usage: See https://github.com/jesseduffield/lazygit/tree/master/pkg/integration/README.md CLI mode: > go run cmd/integration_test/main.go cli [--slow] [--sandbox] ... If you pass no test names, it runs all tests Accepted environment variables: 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 This will open up a terminal UI where you can run tests Help: > 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) } switch os.Args[1] { case "help": fmt.Println(usage) case "cli": slow := false sandbox := false 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": 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) } } lazygit-0.50.0+ds1/demo/000077500000000000000000000000001500612110400146545ustar00rootroot00000000000000lazygit-0.50.0+ds1/demo/README.md000066400000000000000000000000741500612110400161340ustar00rootroot00000000000000This directory contains stuff for recording lazygit demos. lazygit-0.50.0+ds1/demo/config.yml000066400000000000000000000053011500612110400166430ustar00rootroot00000000000000# 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 # Download from: # https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/DejaVuSansMono.zip # Not using the mono font because it makes icons too small. fontFamily: "DejaVuSansM 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" lazygit-0.50.0+ds1/demo/record_demo.sh000077500000000000000000000043311500612110400174760ustar00rootroot00000000000000#!/bin/sh set -e TYPE=$1 TEST=$2 usage() { echo "Usage: $0 [gif|mp4] " echo "e.g. using full path: $0 gif pkg/integration/tests/demo/nuke_working_tree.go" exit 1 } if [ "$#" -ne 2 ] then usage fi if [ "$TYPE" != "gif" ] && [ "$TYPE" != "mp4" ] then usage exit 1 fi if [ -z "$TEST" ] then usage fi WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $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" if ! command -v terminalizer &> /dev/null then echo "terminalizer could not be found" echo "Install it with: npm install -g terminalizer" exit 1 fi if ! command -v "gifsicle" &> /dev/null then echo "gifsicle could not be found" echo "Install it with: npm install -g gifsicle" exit 1 fi # Get last part of the test path and set that as the output name # example test path: pkg/integration/tests/01_basic_test.go # For that we want: NAME=01_basic_test NAME=$(echo "$TEST" | sed -e 's/.*\///' | sed -e 's/\..*//') # Add the demo to the tests list (if missing) so that it can be run 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" # Then we convert it to either an mp4 or gif based on the command line argument if [ "$TYPE" = "mp4" ] 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" else COMPRESSED_PATH="$OUTPUT_DIR/$NAME-compressed.gif" gifsicle --colors 256 --use-col=web -O3 < "$OUTPUT_DIR/$NAME.gif" > "$COMPRESSED_PATH" fi echo "Demo recorded to $COMPRESSED_PATH" lazygit-0.50.0+ds1/docs/000077500000000000000000000000001500612110400146605ustar00rootroot00000000000000lazygit-0.50.0+ds1/docs/Config.md000066400000000000000000001127651500612110400164230ustar00rootroot00000000000000# User Config Default path for the global config file: - Linux: `~/.config/lazygit/config.yml` - MacOS: `~/Library/Application\ Support/lazygit/config.yml` - Windows: `%LOCALAPPDATA%\lazygit\config.yml` (default location, but it will also be found in `%APPDATA%\lazygit\config.yml` For old installations (slightly embarrassing: I didn't realise at the time that you didn't need to supply a vendor name to the path so I just used my name): - Linux: `~/.config/jesseduffield/lazygit/config.yml` - MacOS: `~/Library/Application\ Support/jesseduffield/lazygit/config.yml` - Windows: `%APPDATA%\jesseduffield\lazygit\config.yml` If you want to change the config directory: - MacOS: `export XDG_CONFIG_HOME="$HOME/.config"` In addition to the global config file you can create repo-specific config files in `/.git/lazygit.yml`. Settings in these files override settings in the global config file. In addition, files called `.lazygit.yml` in any of the parent directories of a repo will also be loaded; this can be useful if you have settings that you want to apply to a group of repositories. JSON schema is available for `config.yml` so that IntelliSense in Visual Studio Code (completion and error checking) is automatically enabled when the [YAML Red Hat][yaml] extension is installed. However, note that automatic schema detection only works if your config file is in one of the standard paths mentioned above. If you override the path to the file, you can still make IntelliSense work by adding ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/jesseduffield/lazygit/master/schema/config.json ``` to the top of your config file or via [Visual Studio Code settings.json config][settings]. [yaml]: https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml [settings]: https://github.com/redhat-developer/vscode-yaml#associating-a-schema-to-a-glob-pattern-via-yamlschemas ## Default This is only meant as a reference for what config options exist, and what their default values are. It is not meant to be copied and pasted into your config file as a whole; that's not a good idea for several reasons. It is recommended to include only those settings in your config file that you actually want to change. ```yaml # Config relating to the Lazygit UI gui: # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color authorColors: {} # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color branchColorPatterns: {} # Custom icons for filenames and file extensions # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color customIcons: # Map of filenames to icon properties (icon and color) filenames: {} # Map of file extensions (including the dot) to icon properties (icon and color) extensions: {} # The number of lines you scroll by when scrolling the main window scrollHeight: 2 # If true, allow scrolling past the bottom of the content in the main window scrollPastBottom: true # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#scroll-off-margin scrollOffMargin: 2 # One of: 'margin' (default) | 'jump' scrollOffBehavior: margin # The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs. # Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command. tabWidth: 4 # If true, capture mouse events. # When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS. mouseEvents: true # If true, do not show a warning when discarding changes in the staging view. skipDiscardChangeWarning: false # If true, do not show warning when applying/popping the stash skipStashWarning: false # If true, do not show a warning when attempting to commit without any staged files; instead stage all unstaged files. skipNoStagedFilesWarning: false # If true, do not show a warning when rewording a commit via an external editor skipRewordInEditorWarning: false # Fraction of the total screen width to use for the left side section. You may want to pick a small number (e.g. 0.2) if you're using a narrow screen, so that you can see more of the main section. # Number from 0 to 1.0. sidePanelWidth: 0.3333 # If true, increase the height of the focused side window; creating an accordion effect. expandFocusedSidePanel: false # The weight of the expanded side panel, relative to the other panels. 2 means # twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true. expandedSidePanelWeight: 2 # Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split. # Options are: # - 'horizontal': split the window horizontally # - 'vertical': split the window vertically # - 'flexible': (default) split the window horizontally if the window is wide enough, otherwise split vertically mainPanelSplitMode: flexible # How the window is split when in half screen mode (i.e. after hitting '+' once). # Possible values: # - 'left': split the window horizontally (side panel on the left, main view on the right) # - 'top': split the window vertically (side panel on top, main view below) enlargedSideViewLocation: left # If true, wrap lines in the staging view to the width of the view. This # makes it much easier to work with diffs that have long lines, e.g. # paragraphs of markdown text. wrapLinesInStagingView: true # One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' language: auto # Format used when displaying time e.g. commit time. # Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format timeFormat: 02 Jan 06 # Format used when displaying time if the time is less than 24 hours ago. # Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format shortTimeFormat: 3:04PM # Config relating to colors and styles. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes theme: # Border color of focused window activeBorderColor: - green - bold # Border color of non-focused windows inactiveBorderColor: - default # Border color of focused window when searching in that window searchingActiveBorderColor: - cyan - bold # Color of keybindings help text in the bottom line optionsTextColor: - blue # Background color of selected line. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line selectedLineBgColor: - blue # Background color of selected line when view doesn't have focus. inactiveViewSelectedLineBgColor: - bold # Foreground color of copied commit cherryPickedCommitFgColor: - blue # Background color of copied commit cherryPickedCommitBgColor: - cyan # Foreground color of marked base commit (for rebase) markedBaseCommitFgColor: - blue # Background color of marked base commit (for rebase) markedBaseCommitBgColor: - yellow # Color for file with unstaged changes unstagedChangesColor: - red # Default text color defaultFgColor: - default # Config relating to the commit length indicator commitLength: # If true, show an indicator of commit message length show: true # If true, show the '5 of 20' footer at the bottom of list views showListFooter: true # If true, display the files in the file views as a tree. If false, display the files as a flat list. # This can be toggled from within Lazygit with the '`' key, but that will not change the default. showFileTree: true # If true, show the number of lines changed per file in the Files view showNumstatInFilesView: false # If true, show a random tip in the command log when Lazygit starts showRandomTip: true # If true, show the command log showCommandLog: true # If true, show the bottom line that contains keybinding info and useful buttons. If false, this line will be hidden except to display a loader for an in-progress action. showBottomLine: true # If true, show jump-to-window keybindings in window titles. showPanelJumps: true # Nerd fonts version to use. # One of: '2' | '3' | empty string (default) # If empty, do not show icons. nerdFontsVersion: "" # If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty. showFileIcons: true # Length of author name in (non-expanded) commits view. 2 means show initials only. commitAuthorShortLength: 2 # Length of author name in expanded commits view. 2 means show initials only. commitAuthorLongLength: 17 # Length of commit hash in commits view. 0 shows '*' if NF icons aren't on. commitHashLength: 8 # If true, show commit hashes alongside branch names in the branches view. showBranchCommitHash: false # Whether to show the divergence from the base branch in the branches view. # One of: 'none' | 'onlyArrow' | 'arrowAndNumber' showDivergenceFromBaseBranch: none # Height of the command log view commandLogSize: 8 # Whether to split the main window when viewing file changes. # One of: 'auto' | 'always' # If 'auto', only split the main window when a file has both staged and unstaged changes splitDiff: auto # Default size for focused window. Can be changed from within Lazygit with '+' and '_' (but this won't change the default). # One of: 'normal' (default) | 'half' | 'full' screenMode: normal # Window border style. # One of 'rounded' (default) | 'single' | 'double' | 'hidden' border: rounded # If true, show a seriously epic explosion animation when nuking the working tree. animateExplosion: true # Whether to stack UI components on top of each other. # One of 'auto' (default) | 'always' | 'never' portraitMode: auto # How things are filtered when typing '/'. # One of 'substring' (default) | 'fuzzy' filterMode: substring # Config relating to the spinner. spinner: # The frames of the spinner animation. frames: - '|' - / - '-' - \ # The "speed" of the spinner in milliseconds. rate: 50 # Status panel view. # One of 'dashboard' (default) | 'allBranchesLog' statusPanelView: dashboard # If true, jump to the Files panel after popping a stash switchToFilesAfterStashPop: true # If true, jump to the Files panel after applying a stash switchToFilesAfterStashApply: true # If true, when using the panel jump keys (default 1 through 5) and target panel is already active, go to next tab instead switchTabsWithPanelJumpKeys: false # Config relating to git git: # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md paging: # Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never' colorArg: always # e.g. # diff-so-fancy # delta --dark --paging=never # ydiff -p cat -s --wrap --width={{columnWidth}} pager: "" # If true, Lazygit will use whatever pager is specified in `$GIT_PAGER`, `$PAGER`, or your *git config*. If the pager ends with something like ` | less` we will strip that part out, because less doesn't play nice with our rendering approach. If the custom pager uses less under the hood, that will also break rendering (hence the `--paging=never` flag for the `delta` pager). useConfig: false # e.g. 'difft --color=always' externalDiffCommand: "" # Config relating to committing commit: # If true, pass '--signoff' flag when committing signOff: false # Automatic WYSIWYG wrapping of the commit message as you type autoWrapCommitMessage: true # If autoWrapCommitMessage is true, the width to wrap to autoWrapWidth: 72 # Config relating to merging merging: # If true, run merges in a subprocess so that if a commit message is required, Lazygit will not hang # Only applicable to unix users. manualCommit: false # Extra args passed to `git merge`, e.g. --no-ff args: "" # The commit message to use for a squash merge commit. Can contain "{{selectedRef}}" and "{{currentBranch}}" placeholders. squashMergeMessage: Squash merge {{selectedRef}} into {{currentBranch}} # list of branches that are considered 'main' branches, used when displaying commits mainBranches: - master - main # Prefix to use when skipping hooks. E.g. if set to 'WIP', then pre-commit hooks will be skipped when the commit message starts with 'WIP' skipHookPrefix: WIP # If true, periodically fetch from remote autoFetch: true # If true, periodically refresh files and submodules autoRefresh: true # If not "none", lazygit will automatically forward branches to their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). # Possible values: 'none' | 'onlyMainBranches' | 'allBranches' autoForwardBranches: onlyMainBranches # If true, pass the --all arg to git fetch fetchAll: true # If true, lazygit will automatically stage files that used to have merge # conflicts but no longer do; and it will also ask you if you want to # continue a merge or rebase if you've resolved all conflicts. If false, it # won't do either of these things. autoStageResolvedConflicts: true # Command used when displaying the current branch git log in the main window branchLogCmd: git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} -- # Commands used to display git log of all branches in the main window, they will be cycled in order of appearance (array of strings) allBranchesLogCmds: [] # If true, do not spawn a separate process when using GPG overrideGpg: false # If true, do not allow force pushes disableForcePushing: false # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-commit-message-prefix commitPrefix: [] # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-commit-message-prefix commitPrefixes: {} # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-branch-name-prefix branchPrefix: "" # If true, parse emoji strings in commit messages e.g. render :rocket: as 🚀 # (This should really be under 'gui', not 'git') parseEmoji: false # Config for showing the log in the commits view log: # displays the whole git graph by default in the commits view (equivalent to passing the `--all` argument to `git log`) showWholeGraph: false # When copying commit hashes to the clipboard, truncate them to this # length. Set to 40 to disable truncation. truncateCopiedCommitHashesTo: 12 # Periodic update checks update: # One of: 'prompt' (default) | 'background' | 'never' method: prompt # Period in days between update checks days: 14 # Background refreshes refresher: # File/submodule refresh interval in seconds. # Auto-refresh can be disabled via option 'git.autoRefresh'. refreshInterval: 10 # Re-fetch interval in seconds. # Auto-fetch can be disabled via option 'git.autoFetch'. fetchInterval: 60 # If true, show a confirmation popup before quitting Lazygit confirmOnQuit: false # If true, exit Lazygit when the user presses escape in a context where there is nothing to cancel/close quitOnTopLevelReturn: false # Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc os: # Command for editing a file. Should contain "{{filename}}". edit: "" # Command for editing a file at a given line number. Should contain # "{{filename}}", and may optionally contain "{{line}}". editAtLine: "" # Same as EditAtLine, except that the command needs to wait until the # window is closed. editAtLineAndWait: "" # Whether lazygit suspends until an edit process returns editInTerminal: false # For opening a directory in an editor openDirInEditor: "" # A built-in preset that sets all of the above settings. Supported presets # are defined in the getPreset function in editor_presets.go. editPreset: "" # Command for opening a file, as if the file is double-clicked. Should # contain "{{filename}}", but doesn't support "{{line}}". open: "" # Command for opening a link. Should contain "{{link}}". openLink: "" # CopyToClipboardCmd is the command for copying to clipboard. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard copyToClipboardCmd: "" # ReadFromClipboardCmd is the command for reading the clipboard. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard readFromClipboardCmd: "" # A shell startup file containing shell aliases or shell functions. This will be sourced before running any shell commands, so that shell functions are available in the `:` command prompt or even in custom commands. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#using-aliases-or-functions-in-shell-commands shellFunctionsFile: "" # If true, don't display introductory popups upon opening Lazygit. disableStartupPopups: false # User-configured commands that can be invoked from within Lazygit # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md customCommands: [] # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-pull-request-urls services: {} # What to do when opening Lazygit outside of a git repo. # - 'prompt': (default) ask whether to initialize a new repo or open in the most recent repo # - 'create': initialize a new repo # - 'skip': open most recent repo # - 'quit': exit Lazygit notARepository: prompt # If true, display a confirmation when subprocess terminates. This allows you to view the output of the subprocess before returning to Lazygit. promptToReturnFromSubprocess: true # Keybindings keybinding: universal: quit: q quit-alt1: return: quitWithoutChangingDirectory: Q togglePanel: prevItem: nextItem: prevItem-alt: k nextItem-alt: j prevPage: ',' nextPage: . scrollLeft: H scrollRight: L gotoTop: < gotoBottom: '>' gotoTop-alt: gotoBottom-alt: toggleRangeSelect: v rangeSelectDown: rangeSelectUp: prevBlock: nextBlock: prevBlock-alt: h nextBlock-alt: l nextBlock-alt2: prevBlock-alt2: jumpToBlock: - "1" - "2" - "3" - "4" - "5" focusMainView: "0" nextMatch: "n" prevMatch: "N" startSearch: / optionMenu: optionMenu-alt1: '?' select: goInto: confirm: confirmInEditor: remove: d new: "n" edit: e openFile: o scrollUpMain: scrollDownMain: scrollUpMain-alt1: K scrollDownMain-alt1: J scrollUpMain-alt2: scrollDownMain-alt2: executeShellCommand: ':' createRebaseOptionsMenu: m # 'Files' appended for legacy reasons pushFiles: P # 'Files' appended for legacy reasons pullFiles: p refresh: R createPatchOptionsMenu: nextTab: ']' prevTab: '[' nextScreenMode: + prevScreenMode: _ undo: z redo: filteringMenu: diffingMenu: W diffingMenu-alt: copyToClipboard: openRecentRepos: submitEditorText: extrasMenu: '@' toggleWhitespaceInDiffView: increaseContextInDiffView: '}' decreaseContextInDiffView: '{' increaseRenameSimilarityThreshold: ) decreaseRenameSimilarityThreshold: ( openDiffTool: status: checkForUpdate: u recentRepos: allBranchesLogGraph: a files: commitChanges: c commitChangesWithoutHook: w amendLastCommit: A commitChangesWithEditor: C findBaseCommitForFixup: confirmDiscard: x ignoreFile: i refreshFiles: r stashAllChanges: s viewStashOptions: S toggleStagedAll: a viewResetOptions: D fetch: f toggleTreeView: '`' openMergeTool: M openStatusFilter: copyFileInfoToClipboard: "y" collapseAll: '-' expandAll: = branches: createPullRequest: o viewPullRequestOptions: O copyPullRequestURL: checkoutBranchByName: c forceCheckoutBranch: F rebaseBranch: r renameBranch: R mergeIntoCurrentBranch: M moveCommitsToNewBranch: "N" viewGitFlowOptions: i fastForward: f createTag: T pushTag: P setUpstream: u fetchRemote: f sortOrder: s worktrees: viewWorktreeOptions: w commits: squashDown: s renameCommit: r renameCommitWithEditor: R viewResetOptions: g markCommitAsFixup: f createFixupCommit: F squashAboveCommits: S moveDownCommit: moveUpCommit: amendToCommit: A resetCommitAuthor: a pickCommit: p revertCommit: t cherryPickCopy: C pasteCommits: V markCommitAsBaseForRebase: B tagCommit: T checkoutCommit: resetCherryPick: copyCommitAttributeToClipboard: "y" openLogMenu: openInBrowser: o viewBisectOptions: b startInteractiveRebase: i selectCommitsOfCurrentBranch: '*' amendAttribute: resetAuthor: a setAuthor: A addCoAuthor: c stash: popStash: g renameStash: r commitFiles: checkoutCommitFile: c main: toggleSelectHunk: a pickBothHunks: b editSelectHunk: E submodules: init: i update: u bulkMenu: b commitMessage: commitMenu: ``` ## Platform Defaults ### Windows ```yaml os: open: 'start "" {{filename}}' ``` ### Linux ```yaml os: open: 'xdg-open {{filename}} >/dev/null' ``` ### OSX ```yaml os: open: 'open {{filename}}' ``` ## Custom Command for Opening a Link ```yaml os: openLink: 'bash -C /path/to/your/shell-script.sh {{link}}' ``` Specify the external command to invoke when opening URL links (i.e. creating MR/PR in GitLab, BitBucket or GitHub). `{{link}}` will be replaced by the URL to be opened. A simple shell script can be used to further mangle the passed URL. ## Custom Command for Copying to and Pasting from Clipboard ```yaml os: copyToClipboardCmd: '' ``` Specify an external command to invoke when copying to clipboard is requested. `{{text}` will be replaced by text to be copied. Default is to copy to system clipboard. If you are working on a terminal that supports OSC52, the following command will let you take advantage of it: ```yaml os: copyToClipboardCmd: printf "\033]52;c;$(printf {{text}} | base64 -w 0)\a" > /dev/tty ``` For tmux you need to wrap it with the [tmux escape sequence](https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it), and enable passthrough in tmux config with `set -g allow-passthrough on`: ```yaml os: copyToClipboardCmd: printf "\033Ptmux;\033\033]52;c;$(printf {{text}} | base64 -w 0)\a\033\\" > /dev/tty ``` For the best of both worlds, we can let the command determine if we are running in a tmux session and send the correct sequence: ```yaml os: copyToClipboardCmd: > if [[ "$TERM" =~ ^(screen|tmux) ]]; then printf "\033Ptmux;\033\033]52;c;$(printf {{text}} | base64 -w 0)\a\033\\" > /dev/tty else printf "\033]52;c;$(printf {{text}} | base64 -w 0)\a" > /dev/tty fi ``` A custom command for reading from the clipboard can be set using ```yaml os: readFromClipboardCmd: '' ``` It is used, for example, when pasting a commit message into the commit message panel. The command is supposed to output the clipboard content to stdout. ## Configuring File Editing There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file if you invoke it from the staging panel, for example. To tell lazygit which editor to use for the `e` command, the easiest way to do that is to provide an editPreset config, e.g. ```yaml os: editPreset: 'vscode' ``` Supported presets are `vim`, `nvim`, `nvim-remote`, `lvim`, `emacs`, `nano`, `micro`, `vscode`, `sublime`, `bbedit`, `kakoune`, `helix`, `xcode`, `zed` and `acme`. In many cases lazygit will be able to guess the right preset from your $(git config core.editor), or an environment variable such as $VISUAL or $EDITOR. `nvim-remote` is an experimental preset for when you have invoked lazygit from within a neovim process, allowing lazygit to open the file from within the parent process rather than spawning a new one. If for some reason you are not happy with the default commands from a preset, or there simply is no preset for your editor, you can customize the commands by setting the `edit`, `editAtLine`, and `editAtLineAndWait` options, e.g.: ```yaml os: edit: 'myeditor {{filename}}' editAtLine: 'myeditor --line={{line}} {{filename}}' editAtLineAndWait: 'myeditor --block --line={{line}} {{filename}}' editInTerminal: true openDirInEditor: 'myeditor {{dir}}' ``` The `editInTerminal` option is used to decide whether lazygit needs to suspend itself to the background before calling the editor. It should really be named `suspend` because for some cases like when lazygit is opened from within a neovim session and you're using the `nvim-remote` preset, you're technically still in a terminal. Nonetheless we're sticking with the name `editInTerminal` for backwards compatibility. Contributions of new editor presets are welcome; see the `getPreset` function in [`editor_presets.go`](https://github.com/jesseduffield/lazygit/blob/master/pkg/config/editor_presets.go). ## Using aliases or functions in shell commands Lazygit has a command prompt (`:`) for quickly executing shell commands without having to quit lazygit or switch to a different terminal. Most people find it convenient to have their usual shell aliases or shell functions available at this prompt. To achieve this, put your alias definitions in a separate shell startup file (which you source from your normal startup file, i.e. from `.bashrc` or `.zshrc`), and then tell lazygit about this file like so: ```yml os: shellFunctionsFile: ~/.my_aliases.sh ``` For many people it might work well enough to use their entire shell config file (`~/.bashrc` or `~/.zshrc`) as the `shellFunctionsFile`, but these config files typically do a lot more than defining aliases (e.g. initialize the completion system, start an ssh-agent, etc.) and this may unnecessarily delay execution of shell commands. When using zsh, aliases can't be used here, but functions can. It is easy to convert your existing aliases into functions, just change `alias l="ls -la"` to `l() ls -la`, for example. This way it will work as before both in the shell and in lazygit. Note that the shell aliases file is not only used when executing shell commands, but also for [custom commands](Custom_Command_Keybindings.md), and when opening a file in the editor. ## Overriding default config file location To override the default config directory, use `CONFIG_DIR="$HOME/.config/lazygit"`. This directory contains the config file in addition to some other files lazygit uses to keep track of state across sessions. To override the individual config file used, use the `--use-config-file` arg or the `LG_CONFIG_FILE` env var. If you want to merge a specific config file into a more general config file, perhaps for the sake of setting some theme-specific options, you can supply a list of comma-separated config file paths, like so: ```sh lazygit --use-config-file="$HOME/.base_lg_conf,$HOME/.light_theme_lg_conf" or LG_CONFIG_FILE="$HOME/.base_lg_conf,$HOME/.light_theme_lg_conf" lazygit ``` ## Scroll-off Margin When the selected line gets close to the bottom of the window and you hit down-arrow, there's a feature called "scroll-off margin" that lets the view scroll a little earlier so that you can see a bit of what's coming in the direction that you are moving. This is controlled by the `gui.scrollOffMargin` setting (default: 2), so it keeps 2 lines below the selection visible as you scroll down. It can be set to 0 to scroll only when the selection reaches the bottom of the window. That's the behavior when `gui.scrollOffBehavior` is set to "margin" (the default). If you set `gui.scrollOffBehavior` to "jump", then upon reaching the last line of a view and hitting down-arrow the view will scroll by half a page so that the selection ends up in the middle of the view. This may feel a little jarring because the cursor jumps around when continuously moving down, but it has the advantage that the view doesn't scroll as often. This setting applies both to all list views (e.g. commits and branches etc), and to the staging view. ## Filtering We have two ways to filter things, substring matching (the default) and fuzzy searching. With substring matching, the text you enter gets searched for verbatim (usually case-insensitive, except when your filter string contains uppercase letters, in which case we search case-sensitively). You can search for multiple non-contiguous substrings by separating them with spaces; for example, "int test" will match "integration-testing". All substrings have to match, but not necessarily in the given order. Fuzzy searching is smarter in that it allows every letter of the filter string to match anywhere in the text (only in order though), assigning a weight to the quality of the match and sorting by that order. This has the advantage that it allows typing "clt" to match "commit_loader_test" (letters at the beginning of subwords get more weight); but it has the disadvantage that it tends to return lots of irrelevant results, especially with short filter strings. ## Color Attributes For color attributes you can choose an array of attributes (with max one color attribute) The available attributes are: **Colors** - black - red - green - yellow - blue - magenta - cyan - white - '#ff00ff' **Modifiers** - bold - default - reverse # useful for high-contrast - underline - strikethrough ## Highlighting the selected line If you don't like the default behaviour of highlighting the selected line with a blue background, you can use the `selectedLineBgColor` key to customise the behaviour. If you just want to embolden the selected line (this was the original default), you can do the following: ```yaml gui: theme: selectedLineBgColor: - default ``` You can also use the reverse attribute like so: ```yaml gui: theme: selectedLineBgColor: - reverse ``` ## Custom Author Color Lazygit will assign a random color for every commit author in the commits pane by default. You can customize the color in case you're not happy with the randomly assigned one: ```yaml gui: authorColors: 'John Smith': 'red' # use red for John Smith 'Alan Smithee': '#00ff00' # use green for Alan Smithee ``` You can use wildcard to set a unified color in case your are lazy to customize the color for every author or you just want a single color for all/other authors: ```yaml gui: authorColors: # use red for John Smith 'John Smith': 'red' # use blue for other authors '*': '#0000ff' ``` ## Custom Branch Color You can customize the color of branches based on branch patterns (regular expressions): ```yaml gui: branchColorPatterns: '^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/' 'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-' ``` Note that the regular expressions are not implicitly anchored to the beginning/end of the branch name. If you want to do that, add leading `^` and/or trailing `$` as needed. ## Custom Files Icon & Color You can customize the icon and color of files based on filenames or extensions: ```yaml gui: customIcons: filenames: "CONTRIBUTING.md": { icon: "\uede2", color: "#FEDDEF" } "HACKING.md": { icon: "\uede2", color: "#FEDDEF" } extensions: ".cat": icon: "\U000f011b" color: "#BC4009" ".dog": icon: "\U000f0a43" color: "#B6977E" ``` Note that there is no support for regular expressions. ## Example Coloring ![border example](../../assets/colored-border-example.png) ## Display Nerd Fonts Icons If you are using [Nerd Fonts](https://www.nerdfonts.com), you can display icons. ```yaml gui: nerdFontsVersion: "3" ``` Supported versions are "2" and "3". The deprecated config `showIcons` sets the version to "2" for backwards compatibility. ## Keybindings For all possible keybinding options, check [Custom_Keybindings.md](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md) You can disable certain key bindings by specifying ``. ```yaml keybinding: universal: edit: # disable 'edit file' ``` ### Example Keybindings For Colemak Users ```yaml keybinding: universal: prevItem-alt: 'u' nextItem-alt: 'e' prevBlock-alt: 'n' nextBlock-alt: 'i' nextMatch: '=' prevMatch: '-' new: 'k' edit: 'o' openFile: 'O' scrollUpMain-alt1: 'U' scrollDownMain-alt1: 'E' scrollUpMain-alt2: '' scrollDownMain-alt2: '' undo: 'l' redo: '' diffingMenu: 'M' filteringMenu: '' files: ignoreFile: 'I' commits: moveDownCommit: '' moveUpCommit: '' branches: viewGitFlowOptions: 'I' setUpstream: 'U' ``` ## Custom pull request URLs Some git provider setups (e.g. on-premises GitLab) can have distinct URLs for git-related calls and the web interface/API itself. To work with those, Lazygit needs to know where it needs to create the pull request. You can do so on your `config.yml` file using the following syntax: ```yaml services: '': ':' ``` Where: - `gitDomain` stands for the domain used by git itself (i.e. the one present on clone URLs), e.g. `git.work.com` - `provider` is one of `github`, `bitbucket`, `bitbucketServer`, `azuredevops`, `gitlab` or `gitea` - `webDomain` is the URL where your git service exposes a web interface and APIs, e.g. `gitservice.work.com` ## Predefined commit message prefix In situations where certain naming pattern is used for branches and commits, pattern can be used to populate commit message with prefix that is parsed from the branch name. If you define multiple naming patterns, they will be attempted in order until one matches. Example hitting first match: - Branch name: feature/AB-123 - Generated commit message prefix: [AB-123] Example hitting second match: - Branch name: CD-456_fix_problem - Generated commit message prefix: (CD-456) ```yaml git: commitPrefix: - pattern: "^\\w+\\/(\\w+-\\w+).*" replace: '[$1] ' - pattern: "^([^_]+)_.*" # Take all text prior to the first underscore replace: '($1) ' ``` If you want repository-specific prefixes, you can map them with `commitPrefixes`. If you have both entries in `commitPrefix` defined and an repository match in `commitPrefixes` for the current repo, the `commitPrefixes` entries will be attempted first. Repository folder names must be an exact match. ```yaml git: commitPrefixes: my_project: # This is repository folder name - pattern: "^\\w+\\/(\\w+-\\w+).*" replace: '[$1] ' commitPrefix: - pattern: "^(\\w+)-.*" # A more general match for any leading word replace : '[$1] ' - pattern: ".*" # The final fallthrough regex that copies over the whole branch name replace : '[$0] ' ``` > [!IMPORTANT] > The way golang regex works is when you use `$n` in the replacement string, where `n` is a number, it puts the nth captured subgroup at that place. If `n` is out of range because there aren't that many capture groups in the regex, it puts an empty string there. > > So make sure you are capturing group or groups in your regex. > > For example `^[A-Z]+-\d+$` won't work on branch name like BRANCH-1111 > But `^([A-Z]+-\d+)$` will ## Predefined branch name prefix In situations where certain naming pattern is used for branches, this can be used to populate new branch creation with a static prefix. Example: Some branches: - jsmith/AB-123 - cwilson/AB-125 ```yaml git: branchPrefix: "firstlast/" ``` It's possible to use a dynamic prefix by using the `runCommand` function: ```yaml git: branchPrefix: "firstlast/{{ runCommand "date +\"%Y/%-m\"" }}/" ``` This would produce something like: `firstlast/2025/4/` ## Custom git log command You can override the `git log` command that's used to render the log of the selected branch like so: ``` git: branchLogCmd: "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium --oneline {{branchName}} --" ``` Result: ![](https://i.imgur.com/Nibq35B.png) ## Launching not in a repository behaviour By default, when launching lazygit from a directory that is not a repository, you will be prompted to choose if you would like to initialize a repo. You can override this behaviour in the config with one of the following: ```yaml # for default prompting behaviour notARepository: 'prompt' ``` ```yaml # to skip and initialize a new repo notARepository: 'create' ``` ```yaml # to skip without creating a new repo notARepository: 'skip' ``` ```yaml # to exit immediately if run outside of the Git repository notARepository: 'quit' ``` lazygit-0.50.0+ds1/docs/Custom_Command_Keybindings.md000066400000000000000000000404121500612110400224410ustar00rootroot00000000000000# Custom Command Keybindings You can add custom command keybindings in your config.yml (accessible by pressing 'e' on the status panel from within lazygit) like so: ```yml customCommands: - key: '' context: 'commits' command: 'hub browse -- "commit/{{.SelectedLocalCommit.Hash}}"' - key: 'a' context: 'files' command: "git {{if .SelectedFile.HasUnstagedChanges}} add {{else}} reset {{end}} {{.SelectedFile.Name | quote}}" description: 'Toggle file staged' - key: 'C' context: 'global' command: "git commit" subprocess: true - key: 'n' context: 'localBranches' prompts: - type: 'menu' title: 'What kind of branch is it?' key: 'BranchType' options: - name: 'feature' description: 'a feature branch' value: 'feature' - name: 'hotfix' description: 'a hotfix branch' value: 'hotfix' - name: 'release' description: 'a release branch' value: 'release' - type: 'input' title: 'What is the new branch name?' key: 'BranchName' initialValue: '' command: "git flow {{.Form.BranchType}} start {{.Form.BranchName}}" loadingText: 'Creating branch' ``` Looking at the command assigned to the 'n' key, here's what the result looks like: ![](../../assets/custom-command-keybindings.gif) Custom command keybindings will appear alongside inbuilt keybindings when you view the keybindings menu by pressing '?': ![](https://i.imgur.com/QB21FPx.png) For a given custom command, here are the allowed fields: | _field_ | _description_ | required | |-----------------|----------------------|-| | key | The key to trigger the command. Use a single letter or one of the values from [here](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md). Custom commands without a key specified can be triggered by selecting them from the keybindings (`?`) menu | no | | command | The command to run (using Go template syntax for placeholder values) | yes | | context | The context in which to listen for the key (see [below](#contexts)) | yes | | subprocess | Whether you want the command to run in a subprocess (e.g. if the command requires user input) | no | | prompts | A list of prompts that will request user input before running the final command | no | | loadingText | Text to display while waiting for command to finish | no | | description | Label for the custom command when displayed in the keybindings menu | no | | stream | Whether you want to stream the command's output to the Command Log panel | no | | showOutput | Whether you want to show the command's output in a popup within Lazygit | no | | outputTitle | The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title. | no | | after | Actions to take after the command has completed | no | Here are the options for the `after` key: | _field_ | _description_ | required | |-----------------|----------------------|-| | checkForConflicts | true/false. If true, check for merge conflicts | no | ## Contexts The permitted contexts are: | _context_ | _description_ | | -------------- | -------------------------------------------------------------------------------------------------------- | | status | The 'Status' tab | | files | The 'Files' tab | | worktrees | The 'Worktrees' tab | | localBranches | The 'Local Branches' tab | | remotes | The 'Remotes' tab | | remoteBranches | The context you get when pressing enter on a remote in the remotes tab | | tags | The 'Tags' tab | | commits | The 'Commits' tab | | reflogCommits | The 'Reflog' tab | | subCommits | The context you see when pressing enter on a branch | | commitFiles | The context you see when pressing enter on a commit or stash entry (warning, might be renamed in future) | | stash | The 'Stash' tab | | global | This keybinding will take affect everywhere | > **Bonus** > > You can use a comma-separated string, such as `context: 'commits, subCommits'`, to make it effective in multiple contexts. ## Prompts ### Common fields These fields are applicable to all prompts. | _field_ | _description_ | _required_ | | ------------ | -----------------------------------------------------------------------------------------------| ---------- | | type | One of 'input', 'confirm', 'menu', 'menuFromCommand' | yes | | title | The title to display in the popup panel | no | | key | Used to reference the entered value from within the custom command. E.g. a prompt with `key: 'Branch'` can be referred to as `{{.Form.Branch}}` in the command | yes | ### Input | _field_ | _description_ | _required_ | | ------------ | -----------------------------------------------------------------------------------------------| ---------- | | initialValue | The initial value to appear in the text box | no | | suggestions | Shows suggestions as the input is entered. See below for details | no | The permitted suggestions fields are: | _field_ | _description_ | _required_ | |-----------------|----------------------|-| | preset | Uses built-in logic to obtain the suggestions. One of 'authors', 'branches', 'files', 'refs', 'remotes', 'remoteBranches', 'tags' | no | | command | Command to run such that each line in the output becomes a suggestion. Mutually exclusive with 'preset' field. | no | Here's an example of passing a preset: ```yml customCommands: - key: 'a' command: 'echo {{.Form.Branch | quote}}' context: 'commits' prompts: - type: 'input' title: 'Which branch?' key: 'Branch' suggestions: preset: 'branches' # use built-in logic for obtaining branches ``` Here's an example of passing a command directly: ```yml customCommands: - key: 'a' command: 'echo {{.Form.Branch | quote}}' context: 'commits' prompts: - type: 'input' title: 'Which branch?' key: 'Branch' suggestions: command: "git branch --format='%(refname:short)'" ``` Here's an example of passing an initial value for the input: ```yml customCommands: - key: 'a' command: 'echo {{.Form.Remote | quote}}' context: 'commits' prompts: - type: 'input' title: 'Remote:' key: 'Remote' initialValue: "{{.SelectedRemote.Name}}" ``` ### Confirm | _field_ | _description_ | _required_ | | ------------ | -----------------------------------------------------------------------------------------------| ---------- | | body | The immutable body text to appear in the text box | no | Example: ```yml customCommands: - key: 'a' command: 'echo "pushing to remote"' context: 'commits' prompts: - type: 'confirm' title: 'Push to remote' body: 'Are you sure you want to push to the remote?' ``` ### Menu | _field_ | _description_ | _required_ | | ------------ | -----------------------------------------------------------------------------------------------| ---------- | | options | The options to display in the menu | yes | The permitted option fields are: | _field_ | _description_ | _required_ | |-----------------|----------------------|-| | name | The first part of the label | no | | description | The second part of the label | no | | value | the value that will be used in the command | yes | If an option has no name the value will be displayed to the user in place of the name, so you're allowed to only include the value like so: ```yml customCommands: - key: 'a' command: 'echo {{.Form.BranchType | quote}}' context: 'commits' prompts: - type: 'menu' title: 'What kind of branch is it?' key: 'BranchType' options: - value: 'feature' - value: 'hotfix' - value: 'release' ``` Here's an example of supplying more detail for each option: ```yml customCommands: - key: 'a' command: 'echo {{.Form.BranchType | quote}}' context: 'commits' prompts: - type: 'menu' title: 'What kind of branch is it?' key: 'BranchType' options: - value: 'feature' name: 'feature branch' description: 'branch based off develop' - value: 'hotfix' name: 'hotfix branch' description: 'branch based off main for fast bug fixes' - value: 'release' name: 'release branch' description: 'branch for a release' ``` ### Menu-from-command | _field_ | _description_ | _required_ | | ------------ | -----------------------------------------------------------------------------------------------| ---------- | | command | The command to run to generate menu options | yes | | filter | The regexp to run specifying groups which are going to be kept from the command's output | no | | valueFormat | How to format matched groups from the filter to construct a menu item's value | no | | labelFormat | Like valueFormat but for the labels. If `labelFormat` is not specified, `valueFormat` is shown instead. | no | Here's an example using named groups in the regex. Notice how we can pipe the label to a colour function for coloured output (available colours [here](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md)) ```yml - key : 'a' description: 'Checkout a remote branch as FETCH_HEAD' command: "git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD" context: 'remotes' prompts: - type: 'menuFromCommand' title: 'Remote branch:' key: 'Branch' command: 'git branch -r --list {{.SelectedRemote.Name }}/*' filter: '.*{{.SelectedRemote.Name }}/(?P.*)' valueFormat: '{{ .branch }}' labelFormat: '{{ .branch | green }}' ``` Here's an example using unnamed groups: ```yml - key : 'a' description: 'Checkout a remote branch as FETCH_HEAD' command: "git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD" context: 'remotes' prompts: - type: 'menuFromCommand' title: 'Remote branch:' key: 'Branch' command: 'git branch -r --list {{.SelectedRemote.Name }}/*' filter: '.*{{.SelectedRemote.Name }}/(.*)' valueFormat: '{{ .group_1 }}' labelFormat: '{{ .group_1 | green }}' ``` Here's an example using a command but not specifying anything else: so each line from the command becomes the value and label of the menu items ```yml - key : 'a' description: 'Checkout a remote branch as FETCH_HEAD' command: "open {{.Form.File | quote}}" context: 'global' prompts: - type: 'menuFromCommand' title: 'File:' key: 'File' command: 'ls' ``` ## Placeholder values Your commands can contain placeholder strings using Go's [template syntax](https://jan.newmarch.name/golang/template/chapter-template.html). The template syntax is pretty powerful, letting you do things like conditionals if you want, but for the most part you'll simply want to be accessing the fields on the following objects: ``` SelectedCommit SelectedCommitRange SelectedFile SelectedPath SelectedLocalBranch SelectedRemoteBranch SelectedRemote SelectedTag SelectedStashEntry SelectedCommitFile SelectedWorktree CheckedOutBranch ``` (For legacy reasons, `SelectedLocalCommit`, `SelectedReflogCommit`, and `SelectedSubCommit` are also available, but they are deprecated.) To see what fields are available on e.g. the `SelectedFile`, see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/gui/services/custom_commands/models.go) (all the modelling lives in the same file). We don't support accessing all elements of a range selection yet. We might add this in the future, but as a special case you can access the range of selected commits by using `SelectedCommitRange`, which has two properties `.To` and `.From` which are the hashes of the bottom and top selected commits, respectively. This is useful for passing them to a git command that operates on a range of commits. For example, to create patches for all selected commits, you might use ```yml command: "git format-patch {{.SelectedCommitRange.From}}^..{{.SelectedCommitRange.To}}" ``` We support the following functions: ### Quoting Quote wraps a string in quotes with necessary escaping for the current platform. ``` git {{.SelectedFile.Name | quote}} ``` ### Running a command Runs a command and returns the output. If the command outputs more than a single line, it will produce an error. ``` initialValue: "username/{{ runCommand "date +\"%Y/%-m\"" }}/" ``` ## Keybinding collisions If your custom keybinding collides with an inbuilt keybinding that is defined for the same context, only the custom keybinding will be executed. This also applies to the global context. However, one caveat is that if you have a custom keybinding defined on the global context for some key, and there is an in-built keybinding defined for the same key and for a specific context (say the 'files' context), then the in-built keybinding will take precedence. See how to change in-built keybindings [here](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#keybindings) ## Menus of custom commands For custom commands that are not used very frequently it may be preferable to hide them in a menu; you can assign a key to open the menu, and the commands will appear inside. This has the advantage that you don't have to come up with individual unique keybindings for all those commands that you don't use often; the keybindings for the commands in the menu only need to be unique within the menu. Here is an example: ```yml customCommands: - key: X description: "Copy/paste commits across repos" commandMenu: - key: c command: 'git format-patch --stdout {{.SelectedCommitRange.From}}^..{{.SelectedCommitRange.To}} | pbcopy' context: commits, subCommits description: "Copy selected commits to clipboard" - key: v command: 'pbpaste | git am' context: "commits" description: "Paste selected commits from clipboard" ``` If you use the commandMenu property, none of the other properties except key and description can be used. ## Debugging If you want to verify that your command actually does what you expect, you can wrap it in an 'echo' call and set `showOutput: true` so that it doesn't actually execute the command but you can see how the placeholders were resolved. ## More Examples See the [wiki](https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium) page for more examples, and feel free to add your own custom commands to this page so others can benefit! lazygit-0.50.0+ds1/docs/Custom_Pagers.md000066400000000000000000000054441500612110400177640ustar00rootroot00000000000000# Custom Pagers Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel). Support does not extend to Windows users, because we're making use of a package which doesn't have Windows support. ## Default: ```yaml git: paging: colorArg: always useConfig: false ``` the `colorArg` key is for whether you want the `--color=always` arg in your `git diff` command. Some pagers want it set to `always`, others want it set to `never`. ## Delta: ```yaml git: paging: colorArg: always pager: delta --dark --paging=never ``` ![](https://i.imgur.com/QJpQkF3.png) A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `pager:` config to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor. ## Diff-so-fancy ```yaml git: paging: colorArg: always pager: diff-so-fancy ``` ![](https://i.imgur.com/rjH1TpT.png) ## ydiff ```yaml gui: sidePanelWidth: 0.2 # gives you more space to show things side-by-side git: paging: colorArg: never pager: ydiff -p cat -s --wrap --width={{columnWidth}} ``` ![](https://i.imgur.com/vaa8z0H.png) Be careful with this one, I think the homebrew and pip versions are behind master. I needed to directly download the ydiff script to get the no-pager functionality working. ## Using git config ```yaml git: paging: colorArg: always useConfig: true ``` If you set `useConfig: true`, lazygit will use whatever pager is specified in `$GIT_PAGER`, `$PAGER`, or your *git config*. If the pager ends with something like ` | less` we will strip that part out, because less doesn't play nice with our rendering approach. If the custom pager uses less under the hood, that will also break rendering (hence the `--paging=never` flag for the `delta` pager). ## Using external diff commands Some diff tools can't work as a simple pager like the ones above do, because they need access to the entire diff, so just post-processing git's diff is not enough for them. The most notable example is probably [difftastic](https://difftastic.wilfred.me.uk). These can be used in lazygit by using the `externalDiffCommand` config; in the case of difftastic, that could be ```yaml git: paging: externalDiffCommand: difft --color=always ``` The `colorArg`, `pager`, and `useConfig` options are not used in this case. You can add whatever extra arguments you prefer for your difftool; for instance ```yaml git: paging: externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off ``` lazygit-0.50.0+ds1/docs/Fixup_Commits.md000066400000000000000000000066371500612110400200040ustar00rootroot00000000000000# Fixup Commits ## Background There's this common scenario that you have a PR in review, the reviewer is requesting some changes, and you make those changes and would normally simply squash them into the original commit that they came from. If you do that, however, there's no way for the reviewer to see what you changed. You could just make a separate commit with those changes at the end of the branch, but this is not ideal because it results in a git history that is not very clean. To help with this, git has a concept of fixup commits: you do make a separate commit, but the subject of this commit is the string "fixup! " followed by the original commit subject. This both tells the reviewer what's going on (you are making a change that you later will squash into the designated commit), and it provides an easy way to actually perform this squash operation when you are ready to do that (before merging). ## Creating fixup commits You could of course create fixup commits manually by typing in the commit message with the prefix yourself. But lazygit has an easier way to do that: in the Commits view, select the commit that you want to create a fixup for, and press shift-F (for "Create fixup commit for this commit"). This automatically creates a commit with the appropriate subject line. Don't confuse this with the lowercase "f" command ("Fixup commit"); that one squashes the selected commit into its parent, this is not what we want here. ## Creating amend commits There's a special type of fixup commit that uses "amend!" instead of "fixup!" in the commit message subject; in addition to fixing up the original commit with changes it allows you to also (or only) change the commit message of the original commit. The menu that appears when pressing shift-F has options for both of these; they bring up a commit message panel similar to when you reword a commit, but then create the "amend!" commit containing the new message. Note that in that panel you only type the new message as you want it to be eventually; lazygit then takes care of formatting the "amend!" commit appropriately for you (with the subject of your new message moving into the body of the "amend!" commit). ## Squashing fixup commits When you're ready to merge the branch and want to squash all these fixup commits that you created, that's very easy to do: select the first commit of your branch and hit shift-S (for "Squash all 'fixup!' commits above selected commit (autosquash)"). Boom, done. ## Finding the commit to create a fixup for When you are making changes to code that you changed earlier in a long branch, it can be tedious to find the commit to squash it into. Lazygit has a command to help you with this, too: in the Files view, press ctrl-f to select the right base commit in the Commits view automatically. From there, you can either press shift-F to create a fixup commit for it, or shift-A to amend your changes into the commit if you haven't published your branch yet. If you have many modifications in your working copy, it is a good idea to stage related changes that are meant to go into the same fixup commit; if no changes are staged, ctrl-f works on all unstaged modifications, and then it might show an error if it finds multiple different base commits. If you are interested in what the command does to do its magic, and how you can help it work better, you may want to read the [design document](dev/Find_Base_Commit_For_Fixup_Design.md) that describes this. lazygit-0.50.0+ds1/docs/README.md000066400000000000000000000005341500612110400161410ustar00rootroot00000000000000# Documentation Overview * [Configuration](./Config.md). * [Custom Commands](./Custom_Command_Keybindings.md) * [Custom Pagers](./Custom_Pagers.md) * [Dev docs](./dev) * [Keybindings](./keybindings) * [Undo/Redo](./Undoing.md) * [Range Select](./Range_Select.md) * [Searching/Filtering](./Searching.md) * [Stacked Branches](./Stacked_Branches.md) lazygit-0.50.0+ds1/docs/Range_Select.md000066400000000000000000000021141500612110400175330ustar00rootroot00000000000000# Range Select Some actions can be performed on a range of contiguous items. For example: * staging multiple files at once * squashing multiple commits at once * copying (for cherry-pick) multiple commits at once There are two ways to select a range of items: 1. Sticky range select: Press 'v' to toggle range select, then expand the selection using the up/down arrow key. To reset the selection, press 'v' again. 2. Non-sticky range select: Press shift+up or shift+down to expand the selection. To reset the selection, press up/down without shift. The sticky option will be more familiar to vim users, and the second option will feel more natural to users who aren't used to doing things in a modal way. In order to perform an action on a range of items, simply press the normal key for that action. If the action only works on individual items, it will raise an error. This is a new feature and the plan is to incrementally support range select for more and more actions. If there is an action you would like to support range select which currently does not, please raise an issue in the repo. lazygit-0.50.0+ds1/docs/Searching.md000066400000000000000000000022751500612110400171130ustar00rootroot00000000000000# Searching/Filtering ## View searching/filtering Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`. We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit. If you would like both filtering and searching to be enabled on a given view, please raise an issue for this. ## Filtering files by status You can filter the files view to only show staged/unstaged files by pressing `` in the files view. ## Filtering commits by file path You can filter the commits view to only show commits which contain changes to a given file path. You can do this in a couple of ways: 1) Start lazygit with the -f flag e.g. `lazygit -f my/path` 2) From within lazygit, press `` and then enter the path of the file you want to filter by lazygit-0.50.0+ds1/docs/Stacked_Branches.md000066400000000000000000000017401500612110400203670ustar00rootroot00000000000000# Working with stacked branches When working on a large branch it can often be useful to break it down into smaller pieces, and it can help to create separate branches for each independent chunk of changes. For example, you could have one branch for preparatory refactorings, one for backend changes, and one for frontend changes. Those branches would then all be stacked onto each other. Git has support for rebasing such a stack as a whole; you can enable it by setting the git config `rebase.updateRefs` to true. If you then rebase the topmost branch of the stack, the other ones in the stack will follow. This includes interactive rebases, so for example amending a commit in the first branch of the stack will "just work" in the sense that it keeps the other branches properly stacked onto it. Lazygit visualizes the individual branch heads in the stack by marking them with a cyan asterisk (or a cyan branch symbol if you are using [nerd fonts](Config.md#display-nerd-fonts-icons)). lazygit-0.50.0+ds1/docs/Undoing.md000066400000000000000000000044041500612110400166070ustar00rootroot00000000000000# Undo/Redo in lazygit You can undo the last action by pressing 'z' and redo with `ctrl+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. ![undo](../../assets/demo/undo-compressed.gif) ## How it works If you're as clumsy as me you'll probably have felt the pain of botching an interactive rebase or doing a hard reset onto the wrong commit. Luckily, the reflog allows you to trace your steps and make things right again, but I personally can't stand trying to make sense of the reflog. Lazygit can read through your reflog for you and walk back action by action so that you don't even need to read the reflog. If lazygit finds a reflog entry where you checked out a branch, we'll checkout the original branch. If the entry is from a commit being applied, we'll go back to the commit before that. If we hit an interactive rebase, we'll go back to the commit you were on just before you started it. ## You can even undo things you did outside of lazygit! Because lazygit just uses the reflog to keep track of things, it doesn't matter whether you're trying to undo something you did in lazygit or directly on the command line. You can open lazygit for the first time and start undoing thing in your repo! Likewise, lazygit marks its undos/redos in the reflog so if you quit the application and come back, lazygit still knows where you're up to. ## Limitations There are limitations: firstly, lazygit can only undo things that are recorded in the reflog. That means changes to your working tree or stash aren't covered. Secondly, anything permanent you do like pushing to a remote can't be undone. Thirdly, actions like creating a branch won't be undone, because they're not stored in the reflog. If you are mid-rebase, undo/redo is not supported, because the reflog doesn't contain enough information about what specific things have happened inside that rebase. If you want to undo out of a rebase, it's best to abort the rebase (the default keybinding for bringing up rebase options is 'm'). Undo/Redo is a new feature so if you find a bug let us know. The worst case scenario is that you'll just need to look at your reflog and manually put yourself back on track. lazygit-0.50.0+ds1/docs/dev/000077500000000000000000000000001500612110400154365ustar00rootroot00000000000000lazygit-0.50.0+ds1/docs/dev/Busy.md000066400000000000000000000111661500612110400167070ustar00rootroot00000000000000# Knowing when Lazygit is busy/idle ## The use-case This topic deserves its own doc because there are a few touch points for it. We have a use-case for knowing when Lazygit is idle or busy because integration tests follow the following process: 1) press a key 2) wait until Lazygit is idle 3) run assertion / press another key 4) repeat In the past the process was: 1) press a key 2) run assertion 3) if assertion fails, wait a bit and retry 4) repeat The old process was problematic because an assertion may give a false positive due to the contents of some view not yet having changed since the last key was pressed. ## The solution First, it's important to distinguish three different types of goroutines: * The UI goroutine, of which there is only one, which infinitely processes a queue of events * Worker goroutines, which do some work and then typically enqueue an event in the UI goroutine to display the results * Background goroutines, which periodically spawn worker goroutines (e.g. doing a git fetch every minute) The point of distinguishing worker goroutines from background goroutines is that when any worker goroutine is running, we consider Lazygit to be 'busy', whereas this is not the case with background goroutines. It would be pointless to have background goroutines be considered 'busy' because then Lazygit would be considered busy for the entire duration of the program! In gocui, the underlying package we use for managing the UI and events, we keep track of how many busy goroutines there are using the `Task` type. A task represents some work being done by lazygit. The gocui Gui struct holds a map of tasks and allows creating a new task (which adds it to the map), pausing/continuing a task, and marking a task as done (which removes it from the map). Lazygit is considered to be busy so long as there is at least one busy task in the map; otherwise it's considered idle. When Lazygit goes from busy to idle, it notifies the integration test. It's important that we play by the rules below to ensure that after the user does anything, all the processing that follows happens in a contiguous block of busy-ness with no gaps. ### Spawning a worker goroutine Here's the basic implementation of `OnWorker` (using the same flow as `WaitGroup`s): ```go func (g *Gui) OnWorker(f func(*Task)) { task := g.NewTask() go func() { f(task) task.Done() }() } ``` The crucial thing here is that we create the task _before_ spawning the goroutine, because it means that we'll have at least one busy task in the map until the completion of the goroutine. If we created the task within the goroutine, the current function could exit and Lazygit would be considered idle before the goroutine starts, leading to our integration test prematurely progressing. You typically invoke this with `self.c.OnWorker(f)`. Note that the callback function receives the task. This allows the callback to pause/continue the task (see below). ### Spawning a background goroutine Spawning a background goroutine is as simple as: ```go go utils.Safe(f) ``` Where `utils.Safe` is a helper function that ensures we clean up the gui if the goroutine panics. ### Programmatically enqueueing a UI event This is invoked with `self.c.OnUIThread(f)`. Internally, it creates a task before enqueuing the function as an event (including the task in the event struct) and once that event is processed by the event queue (and any other pending events are processed) the task is removed from the map by calling `task.Done()`. ### Pressing a key If the user presses a key, an event will be enqueued automatically and a task will be created before (and `Done`'d after) the event is processed. ## Special cases There are a couple of special cases where we manually pause/continue the task directly in the client code. These are subject to change but for the sake of completeness: ### Writing to the main view(s) If the user focuses a file in the files panel, we run a `git diff` command for that file and write the output to the main view. But we only read enough of the command's output to fill the view's viewport: further loading only happens if the user scrolls. Given that we have a background goroutine for running the command and writing more output upon scrolling, we create our own task and call `Done` on it as soon as the viewport is filled. ### Requesting credentials from a git command Some git commands (e.g. git push) may request credentials. This is the same deal as above; we use a worker goroutine and manually pause continue its task as we go from waiting on the git command to waiting on user input. This requires passing the task through to the `Push` method so that it can be paused/continued. lazygit-0.50.0+ds1/docs/dev/Codebase_Guide.md000066400000000000000000000275261500612110400206160ustar00rootroot00000000000000# Lazygit Codebase Guide ## Packages * `pkg/app`: Contains startup code, initialises a bunch of stuff like logging, the user config, etc, before starting the gui. Catches and handles some errors that the gui raises. * `pkg/app/daemon`: Contains code relating to the lazygit daemon. This could be better named: it's is not a daemon in the sense that it's a long-running background process; rather it's a short-lived background process that we pass to git for certain tasks, like GIT_EDITOR for when we want to set the TODO file for an interactive rebase. * `pkg/cheatsheet`: Generates the keybinding cheatsheets in `docs/keybindings`. * `pkg/commands/git_commands`: All communication to the git binary happens here. So for example there's a `Checkout` method which calls `git checkout`. * `pkg/commands/oscommands`: Contains code for talking to the OS, and for invoking commands in general * `pkg/commands/git_config`: Reading of the git config all happens here. * `pkg/commands/hosting_service`: Contains code that is specific to git hosting services (aka forges). * `pkg/commands/models`: Contains model structs that represent commits, branches, files, etc. * `pkg/commands/patch`: Contains code for parsing and working with git patches * `pkg/common`: Contains the `Common` struct which holds common dependencies like the logger, i18n, and the user config. Most structs in the code will have a field named `c` which holds a common struct (or a derivative of the common struct). * `pkg/config`: Contains code relating to the Lazygit user config. Specifically `pkg/config/user_config/go` defines the user config struct and its default values. See [below](#using-userconfig) for some important information about using it. * `pkg/constants`: Contains some constant strings (e.g. links to docs) * `pkg/env`: Contains code relating to setting/getting environment variables * `pkg/i18n`: Contains internationalised strings * `pkg/integration`: Contains end-to-end tests * `pkg/jsonschema`: Contains generator for user config JSON schema. * `pkg/logs`: Contains code for instantiating the logger and for tailing the logs via `lazygit --logs` * `pkg/tasks`: Contains code for running asynchronous tasks: mostly related to efficiently rendering command output to the main window. * `pkg/theme`: Contains code related to colour themes. * `pkg/updates`: Contains code related to Lazygit updates (checking for update, download and installing the update) * `pkg/utils`: Contains lots of low-level helper functions * `pkg/gui`: Contains code related to the gui. We've still got a God Struct in the form of our Gui struct, but over time code has been moved out into contexts, controllers, and helpers, and we intend to continue moving more code out over time. * `pkg/gui/context`: Contains code relating to contexts. There is a context for each view e.g. a branches context, a tags context, etc. Contexts manage state related to the view and receive keypresses. * `pkg/gui/controllers`: Contains code relating to controllers. Controllers define a list of keybindings and their associated handlers. One controller can be assigned to multiple contexts, and one context can contain multiple controllers. * `pkg/gui/controllers/helpers`: Contains code that is shared between multiple controllers. * `pkg/gui/filetree`: Contains code relating to the representation of filetrees. * `pkg/gui/keybindings`: Contains code for mapping between keybindings and their labels * `pkg/gui/mergeconflicts`: Contains code relating to the handling of merge conflicts * `pkg/gui/modes`: Contains code relating to the state of different modes e.g. cherry picking mode, rebase mode. * `pkg/gui/patch_exploring`: Contains code relating to the state of patch-oriented views like the staging view. * `pkg/gui/popup`: Contains code that lets you easily raise popups * `pkg/gui/presentation`: Contains presentation code i.e. code concerned with rendering content inside views * `pkg/gui/services/custom_commands`: Contains code related to user-defined custom commands. * `pkg/gui/status`: Contains code for invoking loaders and toasts * `pkg/gui/style`: Contains code for specifying text styles (colour, bold, etc) * `pkg/gui/types`: Contains various gui-specific types and interfaces. Lots of code lives here to avoid circular dependencies * `vendor/github.com/jesseduffield/gocui`: Gocui is the underlying library used for handling the gui event loop, handling keypresses, and rendering the UI. It defines the View struct which our own context structs build upon. ## Important files * `pkg/config/user_config.go`: defines the user config and default values * `pkg/gui/keybindings.go`: defines keybindings which have not yet been moved into a controller (originally all keybindings were defined here) * `pkg/gui/controllers.go`: links up controllers with contexts * `pkg/gui/controllers/helpers/helpers.go`: defines all the different helper structs * `pkg/commands/git.go`: defines all the different git command structs * `pkg/gui/gui.go`: defines the top-level gui state and gui initialisation/run code * `pkg/gui/layout.go`: defines what happens on each render * `pkg/gui/controllers/helpers/window_arrangement_helper.go`: defines the layout of the UI and the size/position of each window * `pkg/gui/context/context.go`: defines the different contexts * `pkg/gui/context/setup.go`: defines initialisation code for all contexts * `pkg/gui/context.go`: manages the lifecycle of contexts, the context stack, and focus changes. * `pkg/gui/types/views.go`: defines views * `pkg/gui/views.go`: defines the ordering of views (front to back) and their initialisation code * `pkg/gui/gui_common.go`: defines gui-specific methods that all controllers and helpers have access to * `pkg/i18n/english.go`: defines the set of i18n strings and their English values * `pkg/gui/controllers/helpers/refresh_helper.go`: manages refreshing of models. The refresh helper is typically invoked at the end of an action to re-load affected models from git (e.g. re-load branches after doing a git pull) * `pkg/gui/controllers/quit_actions.go`: contains code that runs when you hit 'escape' on a view (assuming the view doesn't define its own escape handler) * `vendor/github.com/jesseduffield/gocui/gui.go`: defines the gocui gui struct * `vendor/github.com/jesseduffield/gocui/view.go`: defines the gocui view struct ## Concepts * **View**: Views are defined in the gocui package, and they maintain an internal buffer of content which is rendered each time the screen is drawn. * **Context**: A context is tied to a view and contains some additional state and logic specific to that view e.g. the branches context has code relating specifically to branches, and writes the list of branches to the branches view. Views and contexts share some responsibilities for historical reasons. * **Controller**: A controller defined keybindings with associated handlers. One controller can be assigned to multiple contexts and one context can have multiple controllers. For example the list controller handles keybindings relating to navigating a list, and is assigned to all list contexts (e.g. the branches context). * **Helper**: A helper defines shared code used by controllers, or used by some other parts of the application. Often a controller will have a method that ends up needing to be used by another controller, so in that case we move the method out into a helper so that both controllers can use it. We need to do this because controllers cannot refer to other controllers' methods. In terms of dependencies, controllers sit at the highest level, so they can refer to helpers, contexts, and views (although it's preferable for view-specific code to live in contexts). Helpers can refer to contexts and views, and contexts can only refer to views. Views can't refer to contexts, controllers, or helpers. * **Window**: A window is a section of the screen which will render a view. Windows are named after the default view that appears there, so for example there is a 'stash' window that is so named because by default the stash view appears there. But if you press enter on a stash entry, the stash entry's files will be shown in a different view, but in the same window. * **Panel**: The term 'panel' is still used in a few places to refer to either a view or a window, and it's a term that is now deprecated in favour of 'view' and 'window'. * **Tab**: Each tab in a window (e.g. Files, Worktrees, Submodules) actually has a corresponding view which we bring to the front upon changing tabs. * **Model**: Representation of a git object e.g. commits, branches, files. * **ViewModel**: Used by a context to maintain state related to the view. * **Keybinding**: A keybinding associates a _key_ with an _action_. For example if you press the 'down' arrow, the action performed will be your cursor moving down a list by one. * **Action**: An action is the thing that happens when you press a key. Often an action will invoke a git command, but not always: for example, navigation actions don't involve git. * **Common structs**: Most structs have a field named `c` which contains a 'common' struct: a struct containing a bag of dependencies that most structs of the same layer require. For example if you want to access a helper from a controller you can do so with `self.c.Helpers.MyHelper`. ## Event loop and threads The event loop is managed in the `MainLoop` function of `vendor/github.com/jesseduffield/gocui/gui.go`. Any time there is an event like a key press or a window resize, the event will be processed and then the screen will be redrawn. This involves calling the `layout` function defined in `pkg/gui/layout.go`, which lays out the windows and invokes some on-render hooks. Often, as part of handling a keypress, we'll want to run some code asynchronously so that it doesn't block the UI thread. For this we'll typically run `self.c.OnWorker(myFunc)`. If the worker wants to then do something on the UI thread again it can call `self.c.OnUIThread(myOtherFunc)`. ## Using UserConfig The UserConfig struct is loaded from lazygit's global config file (and possibly repo-specific config files). It can be re-loaded while lazygit is running, e.g. when the user edits one of the config files. In this case we should make sure that any new or changed config values take effect immediately. The easiest way to achieve this is what we do in most controllers or helpers: these have a pointer to the `common.Common` struct, which contains the UserConfig, and access it from there. Since the UserConfig instance in `common.Common` is updated whenever we reload the config, the code can be sure that it always uses an up-to-date value, and there's nothing else to do. If that's not possible for some reason, see if you can add code to `Gui.onUserConfigLoaded` to update things from the new config; there are some examples in that function to use as a guide. If that's too hard to do too, add the config to the list in `Gui.checkForChangedConfigsThatDontAutoReload` so that the user is asked to quit and restart lazygit. ## Legacy code structure Before we had controllers and contexts, all the code lived directly in the gui package under a gui God Struct. This was fairly bloated and so we split things out to have a better separation of concerns. Nonetheless, it's a big effort to migrate all the code so we still have some logic in the gui struct that ought to live somewhere else. Likewise, we have some keybindings defined in `pkg/gui/keybindings.go` that ought to live on a controller (all keybindings used to be defined in that one file). The new structure has its own problems: we don't have a clear guide on whether code should live in a controller or helper. The current approach is to put code in a controller until it's needed by another controller, and to then extract it out into a helper. We may be better off just putting code in helpers to start with and leaving controllers super-thin, with the responsibility of just pairing keys with corresponding helper functions. But it's not clear to me if that would be better than the current approach. lazygit-0.50.0+ds1/docs/dev/Demo_Recordings.md000066400000000000000000000067421500612110400210340ustar00rootroot00000000000000# Demo Recordings We want our demo recordings to be consistent and easy to update if we make changes to Lazygit's UI. Luckily for us, we have an existing recording system for the sake of our integration tests, so we can piggyback on that. You'll want to familiarise yourself with how integration tests are written: see [here](../../pkg/integration/README.md). ## Prerequisites Ideally we'd run this whole thing through docker but we haven't got that working. So you will need: ``` # for recording npm i -g terminalizer # for gif compression npm i -g gifsicle # for mp4 conversion brew install ffmpeg # font with icons wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/DejaVuSansMono.tar.xz && \ tar -xf DejaVuSansMono.tar.xz -C /usr/local/share/fonts && \ rm DejaVuSansMono.tar.xz ``` ## Creating a demo Demos are found in `pkg/integration/tests/demo/`. They are like regular integration tests but have `IsDemo: true` which has a few effects: * The bottom row of the UI is quieter so that we can render captions * Fetch/Push/Pull have artificial latency to mimic a network request * The loader at the bottom-right does not appear In demos, we don't need to be as strict in our assertions as we are in tests. But it's still good to have some basic assertions so that if we automate the process of updating demos we'll know if one of them has broken. You can use the same flow as we use with integration tests when you're writing a demo: * Setup the repo * Run the demo in sandbox mode to get a feel of what needs to happen * Come back and write the code to make it happen ### Adding captions It's good to add captions explaining what task if being performed. Use the existing demos as a guide. ### Setting up the assets worktree We store assets (which includes demo recordings) in the `assets` branch, which is a branch that shares no history with the main branch and exists purely for storing assets. Storing them separately means we don't clog up the code branches with large binaries. The scripts and demo definitions live in the code branches but the output lives in the assets branch so to be able to create a video from a demo you'll need to create a linked worktree for the assets branch which you can do with: ```sh git worktree add .worktrees/assets assets ``` Outputs will be stored in `.worktrees/assets/demos/`. We'll store three separate things: * the yaml of the recording * the original gif * either the compressed gif or the mp4 depending on the output you chose (see below) ### Recording the demo Once you're happy with your demo you can record it using: ```sh scripts/record_demo.sh [gif|mp4] # e.g. scripts/record_demo.sh gif pkg/integration/tests/demo/interactive_rebase.go ``` ~~The gif format is for use in the first video of the readme (it has a larger size but has auto-play and looping)~~ ~~The mp4 format is for everything else (no looping, requires clicking, but smaller size).~~ Turns out that you can't store mp4s in a repo and link them from a README so we're gonna just use gifs across the board for now. ### Including demos in README/docs If you've followed the above steps you'll end up with your output in your assets worktree. Within that worktree, stage all three output files and raise a PR against the assets branch. Then back in the code branch, in the doc, you can embed the recording like so: ```md ![Nuke working tree](../assets/demo/interactive_rebase-compressed.gif) ``` This means we can update assets without needing to update the docs that embed them. lazygit-0.50.0+ds1/docs/dev/Find_Base_Commit_For_Fixup_Design.md000066400000000000000000000265031500612110400243620ustar00rootroot00000000000000# About the mechanics of lazygit's "Find base commit for fixup" command ## Background Lazygit has a command called "Find base commit for fixup" that helps with creating fixup commits. (It is bound to "ctrl-f" by default, and I'll call it simply "the ctrl-f command" throughout the rest of this text for brevity.) It's a heuristic that needs to make a few assumptions; it tends to work well in practice if users are aware of its limitations. The user-facing side of the topic is explained [here](../Fixup_Commits.md). In this document we describe how it works internally, and the design decisions behind it. It is also interesting to compare it to the standalone tool [git-absorb](https://github.com/tummychow/git-absorb) which does a very similar thing, but made different decisions in some cases. We'll explore these differences in this document. ## Design goals I'll start with git-absorb's design goals (my interpretation, since I can't speak for git-absorb's maintainer of course): its main goal seems to be minimum user interaction required. The idea is that you have a PR in review, the reviewer requested a bunch of changes, you make all these changes, so you have a working copy with lots of modified files, and then you fire up git-absorb and it creates all the necessary fixup commits automatically with no further user intervention. While this sounds attractive, it conflicts with ctrl-f's main design goal, which is to support creating high-quality fixups. My philosophy is that fixup commits should have the same high quality standards as normal commits; in particular: - they should be atomic. This means that multiple diff hunks that belong together to form one logical change should be in the same fixup commit. (Not always possible if the logical change needs to be fixed up into several different base commits.) - they should be minimal. Every fixup commit should ideally contain only one logical change, not several unrelated ones. Why is this important? Because fixup commits are mainly a tool for reviewing (if they weren't, you might as well squash the changes into their base commits right away). And reviewing fixup commits is easier if they are well-structured, just like normal commits. The only way to achieve this with git-absorb is to set the `oneFixupPerCommit` config option (for the first goal), and then manually stage the changes that belong together (for the second). This is close to what you have to do with ctrl-f, with one exception that we'll get to below. But ctrl-f enforces this by refusing to do the job if the staged hunks belong to more than one base commit. Git-absorb will happily create multiple fixup commits in this case; ctrl-f doesn't, to enforce that you pay attention to how you group the changes. There's another reason for this behavior: ctrl-f doesn't create fixup commits itself (unlike git-absorb), instead it just selects the found base commit so that the user can decide whether to amend the changes right in, or create a fixup commit from there (both are single-key commands in lazygit). And lazygit doesn't support non-contiguous multiselections of commits, but even if it did, it wouldn't help much in this case. ## The mechanics ### General approach Git-absorb uses a relatively simple approach, and the benefit is of course that it is easy to understand: it looks at every diff hunk separately, and for every hunk it looks at all commits (starting from the newest one backwards) to find the earliest commit that the change can be amended to without conflicts. It is important to realize that "diff hunk" doesn't necessarily mean what you see in the diff view. Git-absorb and ctrl-f both use a context of 0 when diffing your code, so they often see more and smaller hunks than users do. For example, moving a line of code down by one line is a single hunk for users, but it's two separate hunks for git-absorb and ctrl-f; one for deleting the line at the old place, and another one for adding the line at the new place, even if it's only one line further down. From this, it follows that there's one big problem with git-absorb's approach: when moving code, it doesn't realize that the two related hunks of deleting the code from the old place and inserting it at the new place belong together, and often it will manage to create a fixup commit for the first hunk, but leave the other hunk in your working copy as "don't know what to do with this". As an example, suppose your PR is adding a line of code to an existing function, maybe one that declares a new variable, and a reviewer suggests to move this line down a bit, closer to where some other related variables are declared. Moving the line down results in two diff hunks (from the perspective of git-absorb and ctrl-f, as they both use a context of 0 when diffing), and when looking at the second diff hunk in isolation there's no way to find a base commit in your PR for it, because the surrounding code is already on main. To solve this, the ctrl-f command makes a distinction between hunks that have deleted lines and hunks that have only added lines. If the whole diff contains any hunks that have deleted lines, it uses only those hunks to determine the base commit, and then assumes that all the hunks that have only added lines belong into the same commit. This nicely solves the above example of moving code, but also other examples such as the following:
Click to show example Suppose you have a PR in which you added the following function: ```go func findCommit(hash string) (*models.Commit, int, bool) { for i, commit := range self.c.Model().Commits { if commit.Hash == hash { return commit, i, true } } return nil, -1, false } ``` A reviewer suggests to replace the manual `for` loop with a call to `lo.FindIndexOf` since that's less code and more idiomatic. So your modification is this: ```diff --- a/my_file.go +++ b/my_file.go @@ -12,2 +12,3 @@ import ( "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" "golang.org/x/sync/errgroup" @@ -308,9 +309,5 @@ func (self *FixupHelper) blameAddedLines(addedLineHunks []*hunk) ([]string, error func findCommit(hash string) (*models.Commit, int, bool) { - for i, commit := range self.c.Model().Commits { - if commit.Hash == hash { - return commit, i, true - } - } - - return nil, -1, false + return lo.FindIndexOf(self.c.Model().Commits, func(commit *models.Commit) bool { + return commit.Hash == hash + }) } ``` If we were to look at these two hunks separately, we'd easily find the base commit for the second one, but we wouldn't find the one for the first hunk because the imports around the added import have been on main for a long time. In fact, git-absorb leaves this hunk in the working copy because it doesn't know what to do with it.
Only if there are no hunks with deleted lines does ctrl-f look at the hunks with only added lines and determines the base commit for them. This solves cases like adding a comment above a function that you added in your PR. The downside of this more complicated approach is that it relies on the user staging related hunks correctly. However, in my experience this is easy to do and not very error-prone, as long as users are aware of this behavior. Lazygit tries to help making them aware of it by showing a warning whenever there are hunks with only added lines in addition to hunks with deleted lines. ### Finding the base commit for a given hunk As explained above, git-absorb finds the base commit by walking the commits backwards until it finds one that conflicts with the hunk, and then the found base commit is the one just before that one. This works reliably, but it is slow. Ctrl-f uses a different approach that is usually much faster, but should always yield the same result. Again, it makes a distinction between hunks with deleted lines and hunks with only added lines. For hunks with deleted lines it performs a line range blame for all the deleted lines (e.g. `git blame -L42,+3 -- filename`), and if the result is the same for all deleted lines, then that's the base commit; otherwise it returns an error. For hunks with only added lines, it gets a little more complicated. We blame the single lines just before and just after the hunk (I'll ignore the edge cases of either of those not existing because the hunk is at the beginning or end of the file; read the code to see how we handle these cases). If the blame result is the same for both, then that's the base commit. This is the case of adding a line in the middle of a block of code that was added in the PR. Otherwise, the base commit is the more recent of the two (and in this case it doesn't matter if the other one is an earlier commit in the current branch, or a possibly very old commit that's already on main). This covers the common case of adding a comment to a function that was added in the PR, but also adding another line at the end of a block of code that was added in the base commit. It's interesting to discuss what "more recent" means here. You could say if commit A is an ancestor of commit B (or in other words, A is reachable from B) then B is the more recent one. And if none of the two commits is reachable from the other, you have an error case because it's unclear which of the two should be considered the base commit. The scenario in which this happens is a commit history like this: ``` C---D / \ A---B---E---F---G ``` where, for instance, D and E are the two blame results. Unfortunately, determining the ancestry relationship between two commits using git commands is a bit expensive and not totally straightforward. Fortunately, it's not necessary in lazygit because lazygit has the most recent 300 commits cached in memory, and can simply search its linear list of commits to see which one is closer to the beginning of the list. If only one of the two commits is found within those 300 commits, then that's the more recent one; if neither is found, we assume that both commits are on main and error out. In the merge scenario pictured above, we arbitrarily return one of the two commits (this will depend on the log order), but that's probably fine as this scenario should be extremely rare in practice; in most cases, feature branches are simply linear. ### Knowing where to stop searching Git-absorb needs to know when to stop walking backwards searching for commits, since it doesn't make sense to create fixups for commits that are already on main. However, it doesn't know where the current branch ends and main starts, so it needs to rely on user input for this. By default it searches the most recent 10 commits, but this can be overridden with a config setting. In longer branches this is often not enough for finding the base commit; but setting it to a higher value causes the command to take longer to complete when the base commit can't be found. Lazygit doesn't have this problem. For a given blame result it needs to determine whether that commit is already on main, and if it can find the commit in its cached list of the first 300 commits it can get that information from there, because lazygit knows what the user's configured main branches are (`master` and `main` by default, but it could also include branches like `devel` or `1.0-hotfixes`), and so it can tell for each commit whether it's contained in one of those main branches. And if it can't find it among the first 300 commits, it assumes the commit already on main, on the assumption that no feature branch has more than 300 commits. lazygit-0.50.0+ds1/docs/dev/Integration_Tests.md000066400000000000000000000000651500612110400214260ustar00rootroot00000000000000see new docs [here](../../pkg/integration/README.md) lazygit-0.50.0+ds1/docs/dev/Profiling.md000066400000000000000000000040371500612110400177150ustar00rootroot00000000000000# Profiling Lazygit If you want to investigate what's contributing to CPU or memory usage, start lazygit with the `-profile` command line flag. This tells it to start an integrated web server that listens for profiling requests. ## Save profile data ### CPU While lazygit is running with the `-profile` flag, perform a CPU profile and save it to a file by running this command in another terminal window: ```sh curl -o cpu.out http://127.0.0.1:6060/debug/pprof/profile ``` By default, it profiles for 30 seconds. To change the duration, use ```sh curl -o cpu.out 'http://127.0.0.1:6060/debug/pprof/profile?seconds=60' ``` ### Memory To save a heap profile (containing information about all memory allocated so far since startup), use ```sh curl -o mem.out http://127.0.0.1:6060/debug/pprof/heap ``` Sometimes it can be useful to get a delta log, i.e. to see how memory usage developed from one point in time to another. For that, use ```sh curl -o mem.out 'http://127.0.0.1:6060/debug/pprof/heap?seconds=20' ``` This will log the memory usage difference between now and 20 seconds later, so it gives you 20 seconds to perform the action in lazygit that you are interested in measuring. ## View profile data To display the profile data, you can either use speedscope.app, or the pprof tool that comes with go. I prefer the former because it has a nicer UI and is a little more powerful; however, I have seen cases where it wasn't able to load a profile for some reason, in which case it's good to have the pprof tool as a fallback. ### Speedscope.app Go to https://www.speedscope.app/ in your browser, and drag the saved profile onto the browser window. Refer to [the documentation](https://github.com/jlfwong/speedscope?tab=readme-ov-file#usage) for how to navigate the data. ### Pprof tool To view a profile that you saved as `cpu.out`, use ```sh go tool pprof -http=:8080 cpu.out ``` By default this shows the graph view, which I don't find very useful myself. Choose "Flame Graph" from the View menu to show a much more useful representation of the data. lazygit-0.50.0+ds1/docs/dev/README.md000066400000000000000000000004611500612110400167160ustar00rootroot00000000000000# Dev Documentation Overview * [Codebase Guide](./Codebase_Guide.md) * [Busy/Idle Tracking](./Busy.md) * [Integration Tests](../../pkg/integration/README.md) * [Demo Recordings](./Demo_Recordings.md) * [Find base commit for fixup design](Find_Base_Commit_For_Fixup_Design.md) * [Profiling](Profiling.md) lazygit-0.50.0+ds1/docs/keybindings/000077500000000000000000000000001500612110400171665ustar00rootroot00000000000000lazygit-0.50.0+ds1/docs/keybindings/Custom_Keybindings.md000066400000000000000000000042221500612110400233100ustar00rootroot00000000000000## Possible keybindings | Put in | You will get | |---------------|----------------| | `` | F1 | | `` | F2 | | `` | F3 | | `` | F4 | | `` | F5 | | `` | F6 | | `` | F7 | | `` | F8 | | `` | F9 | | `` | F10 | | `` | F11 | | `` | F12 | | `` | Insert | | `` | Delete | | `` | Home | | `` | End | | `` | Pgup | | `` | Pgdn | | `` | ArrowUp | | `` | ShiftArrowUp | | `` | ArrowDown | | `` | ShiftArrowDown | | `` | ArrowLeft | | `` | ArrowRight | | `` | Tab | | `` | Backtab | | `` | Enter | | `` | AltEnter | | `` | Esc | | `` | Backspace | | `` | CtrlSpace | | `` | CtrlSlash | | `` | Space | | `` | CtrlA | | `` | CtrlB | | `` | CtrlC | | `` | CtrlD | | `` | CtrlE | | `` | CtrlF | | `` | CtrlG | | `` | CtrlJ | | `` | CtrlK | | `` | CtrlL | | `` | CtrlN | | `` | CtrlO | | `` | CtrlP | | `` | CtrlQ | | `` | CtrlR | | `` | CtrlS | | `` | CtrlT | | `` | CtrlU | | `` | CtrlV | | `` | CtrlW | | `` | CtrlX | | `` | CtrlY | | `` | CtrlZ | | `` | Ctrl4 | | `` | Ctrl5 | | `` | Ctrl6 | | `` | Ctrl8 | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_en.md000066400000000000000000000577631500612110400224620ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit Keybindings _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ ## Global keybindings | Key | Action | Info | |-----|--------|-------------| | `` `` | Switch to a recent repo | | | `` (fn+up/shift+k) `` | Scroll up main window | | | `` (fn+down/shift+j) `` | Scroll down main window | | | `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view. | | `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view. | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | | `` `` | View custom patch options | | | `` m `` | View merge/rebase options | View options to abort/continue/skip the current merge/rebase. | | `` R `` | Refresh | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Next screen mode (normal/half/fullscreen) | | | `` _ `` | Prev screen mode | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q `` | Quit | | | `` `` | Cancel | | | `` `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view. | | `` z `` | Undo | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | | `` `` | Redo | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | ## List panel navigation | Key | Action | Info | |-----|--------|-------------| | `` , `` | Previous page | | | `` . `` | Next page | | | `` < () `` | Scroll to top | | | `` > () `` | Scroll to bottom | | | `` v `` | Toggle range select | | | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | Search the current view by text | | | `` H `` | Scroll left | | | `` L `` | Scroll right | | | `` ] `` | Next tab | | | `` [ `` | Previous tab | | ## Commit files | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy path to clipboard | | | `` y `` | Copy to clipboard | | | `` c `` | Checkout | Checkout file. This replaces the file in your working tree with the version from the selected commit. | | `` d `` | Remove | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. | | `` o `` | Open file | Open file in default application. | | `` e `` | Edit | Open file in external editor. | | `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter file / Toggle directory collapsed | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | | `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | Search the current view by text | | ## Commit summary | Key | Action | Info | |-----|--------|-------------| | `` `` | Confirm | | | `` `` | Close | | ## Commits | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy commit hash to clipboard | | | `` `` | Reset copied (cherry-picked) commits selection | | | `` b `` | View bisect options | | | `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. | | `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. | | `` r `` | Reword | Reword the selected commit's message. | | `` R `` | Reword with editor | | | `` d `` | Drop | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. | | `` e `` | Edit (start interactive rebase) | Edit the selected commit. Use this to start an interactive rebase from the selected commit. When already mid-rebase, this will mark the selected commit for editing, which means that upon continuing the rebase, the rebase will pause at the selected commit to allow you to make changes. | | `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit. If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | Pick | Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase. | | `` F `` | Create fixup commit | Create 'fixup!' commit for the selected commit. Later on, you can press `S` on this same commit to apply all above fixup commits. | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash). | | `` `` | Move commit down one | | | `` `` | Move commit up one | | | `` V `` | Paste (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase. | | `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` `` | Checkout | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | | `` w `` | View worktree options | | | `` / `` | Search the current view by text | | ## Confirmation panel | Key | Action | Info | |-----|--------|-------------| | `` `` | Confirm | | | `` `` | Close/Cancel | | ## Files | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy path to clipboard | | | `` `` | Stage | Toggle staged for selected file. | | `` `` | Filter files by status | | | `` y `` | Copy to clipboard | | | `` c `` | Commit | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | | `` A `` | Amend last commit | | | `` C `` | Commit changes using git editor | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | Edit | Open file in external editor. | | `` o `` | Open file | Open file in default application. | | `` i `` | Ignore or exclude file | | | `` r `` | Refresh files | | | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | View stash options | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | Stage all | Toggle staged/unstaged for all files in working tree. | | `` `` | Stage lines / Collapse directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | | `` d `` | Discard | View options for discarding changes to the selected file. | | `` g `` | View upstream reset options | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` `` | Open external diff tool (git difftool) | | | `` M `` | Open external merge tool | Run `git mergetool`. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | Search the current view by text | | ## Local branches | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy branch name to clipboard | | | `` i `` | Show git-flow options | | | `` `` | Checkout | Checkout selected item. | | `` n `` | New branch | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | Create pull request | | | `` O `` | View create pull request options | | | `` `` | Copy pull request URL to clipboard | | | `` c `` | Checkout by name | Checkout by name. In the input box you can enter '-' to switch to the last branch. | | `` F `` | Force checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Delete | View delete options for local/remote branch. | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward | Fast-forward selected branch from its upstream. | | `` T `` | New tag | | | `` s `` | Sort order | | | `` g `` | Reset | | | `` R `` | Rename branch | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Main panel (merging) | Key | Action | Info | |-----|--------|-------------| | `` `` | Pick hunk | | | `` b `` | Pick all hunks | | | `` `` | Previous hunk | | | `` `` | Next hunk | | | `` `` | Previous conflict | | | `` `` | Next conflict | | | `` z `` | Undo | Undo last merge conflict resolution. | | `` e `` | Edit file | Open file in external editor. | | `` o `` | Open file | Open file in default application. | | `` M `` | Open external merge tool | Run `git mergetool`. | | `` `` | Return to files panel | | ## Main panel (normal) | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | Scroll down | | | `` mouse wheel up (fn+down) `` | Scroll up | | | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | ## Main panel (patch building) | Key | Action | Info | |-----|--------|-------------| | `` `` | Go to previous hunk | | | `` `` | Go to next hunk | | | `` v `` | Toggle range select | | | `` a `` | Select hunk | Toggle hunk selection mode. | | `` `` | Copy selected text to clipboard | | | `` o `` | Open file | Open file in default application. | | `` e `` | Edit file | Open file in external editor. | | `` `` | Toggle lines in patch | | | `` `` | Exit custom patch builder | | | `` / `` | Search the current view by text | | ## Main panel (staging) | Key | Action | Info | |-----|--------|-------------| | `` `` | Go to previous hunk | | | `` `` | Go to next hunk | | | `` v `` | Toggle range select | | | `` a `` | Select hunk | Toggle hunk selection mode. | | `` `` | Copy selected text to clipboard | | | `` `` | Stage | Toggle selection staged / unstaged. | | `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | Open file | Open file in default application. | | `` e `` | Edit file | Open file in external editor. | | `` `` | Return to files panel | | | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | | `` c `` | Commit | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | | `` C `` | Commit changes using git editor | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | Search the current view by text | | ## Menu | Key | Action | Info | |-----|--------|-------------| | `` `` | Execute | | | `` `` | Close | | | `` / `` | Filter the current view by text | | ## Reflog | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy commit hash to clipboard | | | `` `` | Checkout | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset copied (cherry-picked) commits selection | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remote branches | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy branch name to clipboard | | | `` `` | Checkout | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | New branch | | | `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | | `` u `` | Set as upstream | Set the selected remote branch as the upstream of the checked-out branch. | | `` s `` | Sort order | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remotes | Key | Action | Info | |-----|--------|-------------| | `` `` | View branches | | | `` n `` | New remote | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Edit the selected remote's name or URL. | | `` f `` | Fetch | Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches. | | `` / `` | Filter the current view by text | | ## Secondary | Key | Action | Info | |-----|--------|-------------| | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | ## Stash | Key | Action | Info | |-----|--------|-------------| | `` `` | Apply | Apply the stash entry to your working directory. | | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Drop | Remove the stash entry from the stash list. | | `` n `` | New branch | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | | `` r `` | Rename stash | | | `` 0 `` | Focus main view | | | `` `` | View files | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Status | Key | Action | Info | |-----|--------|-------------| | `` o `` | Open config file | Open file in default application. | | `` e `` | Edit config file | Open file in external editor. | | `` u `` | Check for update | | | `` `` | Switch to a recent repo | | | `` a `` | Show/cycle all branch logs | | ## Sub-commits | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy commit hash to clipboard | | | `` `` | Checkout | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset copied (cherry-picked) commits selection | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | | `` w `` | View worktree options | | | `` / `` | Search the current view by text | | ## Submodules | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy submodule name to clipboard | | | `` `` | Enter | Enter submodule. After entering the submodule, you can press `` to escape back to the parent repo. | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Update selected submodule. | | `` n `` | New submodule | | | `` e `` | Update submodule URL | | | `` i `` | Initialize | Initialize the selected submodule to prepare for fetching. You probably want to follow this up by invoking the 'update' action to fetch the submodule. | | `` b `` | View bulk submodule options | | | `` / `` | Filter the current view by text | | ## Tags | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | Checkout | Checkout the selected tag as a detached HEAD. | | `` n `` | New tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | Delete | View delete options for local/remote tag. | | `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Worktrees | Key | Action | Info | |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | | `` o `` | Open in editor | | | `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | Filter the current view by text | | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_ja.md000066400000000000000000000621751500612110400224430ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit キーバインド _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ ## グローバルキーバインド | Key | Action | Info | |-----|--------|-------------| | `` `` | 最近使用したリポジトリに切り替え | | | `` (fn+up/shift+k) `` | メインパネルを上にスクロール | | | `` (fn+down/shift+j) `` | メインパネルを下にスクロール | | | `` @ `` | コマンドログメニューを開く | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view. | | `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view. | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | | `` `` | View custom patch options | | | `` m `` | View merge/rebase options | View options to abort/continue/skip the current merge/rebase. | | `` R `` | リフレッシュ | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | 次のスクリーンモード (normal/half/fullscreen) | | | `` _ `` | 前のスクリーンモード | | | `` ? `` | メニューを開く | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | 差分メニューを開く | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | 差分メニューを開く | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q `` | 終了 | | | `` `` | キャンセル | | | `` `` | 空白文字の差分の表示有無を切り替え | Toggle whether or not whitespace changes are shown in the diff view. | | `` z `` | アンドゥ (via reflog) (experimental) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | | `` `` | リドゥ (via reflog) (experimental) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | ## 一覧パネルの操作 | Key | Action | Info | |-----|--------|-------------| | `` , `` | 前のページ | | | `` . `` | 次のページ | | | `` < () `` | 最上部までスクロール | | | `` > () `` | 最下部までスクロール | | | `` v `` | 範囲選択を切り替え | | | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | 検索を開始 | | | `` H `` | 左スクロール | | | `` L `` | 右スクロール | | | `` ] `` | 次のタブ | | | `` [ `` | 前のタブ | | ## Secondary | Key | Action | Info | |-----|--------|-------------| | `` `` | パネルを切り替え | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 検索を開始 | | ## Stash | Key | Action | Info | |-----|--------|-------------| | `` `` | 適用 | Apply the stash entry to your working directory. | | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Drop | Remove the stash entry from the stash list. | | `` n `` | 新しいブランチを作成 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | | `` r `` | Stashを変更 | | | `` 0 `` | Focus main view | | | `` `` | View files | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Sub-commits | Key | Action | Info | |-----|--------|-------------| | `` `` | コミットのhashをクリップボードにコピー | | | `` `` | チェックアウト | Checkout the selected commit as a detached HEAD. | | `` y `` | コミットの情報をコピー | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットにブランチを作成 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | コミットをコピー (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset copied (cherry-picked) commits selection | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | | `` w `` | View worktree options | | | `` / `` | 検索を開始 | | ## Worktrees | Key | Action | Info | |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | | `` o `` | Open in editor | | | `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | Filter the current view by text | | ## コミット | Key | Action | Info | |-----|--------|-------------| | `` `` | コミットのhashをクリップボードにコピー | | | `` `` | Reset copied (cherry-picked) commits selection | | | `` b `` | View bisect options | | | `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. | | `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. | | `` r `` | コミットメッセージを変更 | Reword the selected commit's message. | | `` R `` | エディタでコミットメッセージを編集 | | | `` d `` | コミットを削除 | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. | | `` e `` | Edit (start interactive rebase) | コミットを編集 | | `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit. If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | 選択 | Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase. | | `` F `` | Fixupコミットを作成 | このコミットに対するfixupコミットを作成 | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash). | | `` `` | コミットを1つ下に移動 | | | `` `` | コミットを1つ上に移動 | | | `` V `` | コミットを貼り付け (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | ステージされた変更でamendコミット | | `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | タグを作成 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | ログメニューを開く | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` `` | チェックアウト | Checkout the selected commit as a detached HEAD. | | `` y `` | コミットの情報をコピー | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットにブランチを作成 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | コミットをコピー (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | | `` w `` | View worktree options | | | `` / `` | 検索を開始 | | ## コミットファイル | Key | Action | Info | |-----|--------|-------------| | `` `` | ファイル名をクリップボードにコピー | | | `` y `` | Copy to clipboard | | | `` c `` | チェックアウト | Checkout file. This replaces the file in your working tree with the version from the selected commit. | | `` d `` | Remove | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. | | `` o `` | ファイルを開く | Open file in default application. | | `` e `` | 編集 | Open file in external editor. | | `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter file / Toggle directory collapsed | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | | `` ` `` | ファイルツリーの表示を切り替え | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | 検索を開始 | | ## コミットメッセージ | Key | Action | Info | |-----|--------|-------------| | `` `` | 確認 | | | `` `` | 閉じる | | ## サブモジュール | Key | Action | Info | |-----|--------|-------------| | `` `` | サブモジュール名をクリップボードにコピー | | | `` `` | Enter | サブモジュールを開く | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | サブモジュールを更新 | | `` n `` | サブモジュールを新規追加 | | | `` e `` | サブモジュールのURLを更新 | | | `` i `` | Initialize | サブモジュールを初期化 | | `` b `` | View bulk submodule options | | | `` / `` | Filter the current view by text | | ## ステータス | Key | Action | Info | |-----|--------|-------------| | `` o `` | 設定ファイルを開く | Open file in default application. | | `` e `` | 設定ファイルを編集 | Open file in external editor. | | `` u `` | 更新を確認 | | | `` `` | 最近使用したリポジトリに切り替え | | | `` a `` | Show/cycle all branch logs | | ## タグ | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | チェックアウト | Checkout the selected tag as a detached HEAD. | | `` n `` | タグを作成 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | Delete | View delete options for local/remote tag. | | `` P `` | タグをpush | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | コミットを閲覧 | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## ファイル | Key | Action | Info | |-----|--------|-------------| | `` `` | ファイル名をクリップボードにコピー | | | `` `` | ステージ/アンステージ | Toggle staged for selected file. | | `` `` | ファイルをフィルタ (ステージ/アンステージ) | | | `` y `` | Copy to clipboard | | | `` c `` | 変更をコミット | Commit staged changes. | | `` w `` | pre-commitフックを実行せずに変更をコミット | | | `` A `` | 最新のコミットにamend | | | `` C `` | gitエディタを使用して変更をコミット | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | 編集 | Open file in external editor. | | `` o `` | ファイルを開く | Open file in default application. | | `` i `` | ファイルをignore | | | `` r `` | ファイルをリフレッシュ | | | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | View stash options | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | すべての変更をステージ/アンステージ | Toggle staged/unstaged for all files in working tree. | | `` `` | Stage lines / Collapse directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | | `` d `` | Discard | View options for discarding changes to the selected file. | | `` g `` | View upstream reset options | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | ファイルツリーの表示を切り替え | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` `` | Open external diff tool (git difftool) | | | `` M `` | Git mergetoolを開く | Run `git mergetool`. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | 検索を開始 | | ## ブランチ | Key | Action | Info | |-----|--------|-------------| | `` `` | ブランチ名をクリップボードにコピー | | | `` i `` | Show git-flow options | | | `` `` | チェックアウト | Checkout selected item. | | `` n `` | 新しいブランチを作成 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | Pull Requestを作成 | | | `` O `` | View create pull request options | | | `` `` | Pull RequestのURLをクリップボードにコピー | | | `` c `` | Checkout by name | Checkout by name. In the input box you can enter '-' to switch to the last branch. | | `` F `` | 強制的にチェックアウト | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Delete | View delete options for local/remote branch. | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` M `` | 現在のブランチにマージ | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward | Fast-forward selected branch from its upstream. | | `` T `` | タグを作成 | | | `` s `` | 並び替え | | | `` g `` | Reset | | | `` R `` | ブランチ名を変更 | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | コミットを閲覧 | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## メインパネル (Merging) | Key | Action | Info | |-----|--------|-------------| | `` `` | Pick hunk | | | `` b `` | Pick all hunks | | | `` `` | 前のhunkを選択 | | | `` `` | 次のhunkを選択 | | | `` `` | 前のコンフリクトを選択 | | | `` `` | 次のコンフリクトを選択 | | | `` z `` | アンドゥ | Undo last merge conflict resolution. | | `` e `` | ファイルを編集 | Open file in external editor. | | `` o `` | ファイルを開く | Open file in default application. | | `` M `` | Git mergetoolを開く | Run `git mergetool`. | | `` `` | ファイル一覧に戻る | | ## メインパネル (Normal) | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | 下にスクロール | | | `` mouse wheel up (fn+down) `` | 上にスクロール | | | `` `` | パネルを切り替え | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 検索を開始 | | ## メインパネル (Patch Building) | Key | Action | Info | |-----|--------|-------------| | `` `` | 前のhunkを選択 | | | `` `` | 次のhunkを選択 | | | `` v `` | 範囲選択を切り替え | | | `` a `` | Hunk選択を切り替え | Toggle hunk selection mode. | | `` `` | 選択されたテキストをクリップボードにコピー | | | `` o `` | ファイルを開く | Open file in default application. | | `` e `` | ファイルを編集 | Open file in external editor. | | `` `` | 行をパッチに追加/削除 | | | `` `` | Exit custom patch builder | | | `` / `` | 検索を開始 | | ## メインパネル (Staging) | Key | Action | Info | |-----|--------|-------------| | `` `` | 前のhunkを選択 | | | `` `` | 次のhunkを選択 | | | `` v `` | 範囲選択を切り替え | | | `` a `` | Hunk選択を切り替え | Toggle hunk selection mode. | | `` `` | 選択されたテキストをクリップボードにコピー | | | `` `` | ステージ/アンステージ | 選択行をステージ/アンステージ | | `` d `` | 変更を削除 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | ファイルを開く | Open file in default application. | | `` e `` | ファイルを編集 | Open file in external editor. | | `` `` | ファイル一覧に戻る | | | `` `` | パネルを切り替え | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | | `` c `` | 変更をコミット | Commit staged changes. | | `` w `` | pre-commitフックを実行せずに変更をコミット | | | `` C `` | gitエディタを使用して変更をコミット | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | 検索を開始 | | ## メニュー | Key | Action | Info | |-----|--------|-------------| | `` `` | 実行 | | | `` `` | 閉じる | | | `` / `` | Filter the current view by text | | ## リモート | Key | Action | Info | |-----|--------|-------------| | `` `` | View branches | | | `` n `` | リモートを新規追加 | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | 編集 | リモートを編集 | | `` f `` | Fetch | リモートをfetch | | `` / `` | Filter the current view by text | | ## リモートブランチ | Key | Action | Info | |-----|--------|-------------| | `` `` | ブランチ名をクリップボードにコピー | | | `` `` | チェックアウト | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 新しいブランチを作成 | | | `` M `` | 現在のブランチにマージ | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | | `` u `` | Set as upstream | Set the selected remote branch as the upstream of the checked-out branch. | | `` s `` | 並び替え | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | コミットを閲覧 | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 参照ログ | Key | Action | Info | |-----|--------|-------------| | `` `` | コミットのhashをクリップボードにコピー | | | `` `` | チェックアウト | Checkout the selected commit as a detached HEAD. | | `` y `` | コミットの情報をコピー | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットにブランチを作成 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | コミットをコピー (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset copied (cherry-picked) commits selection | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | コミットを閲覧 | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 確認パネル | Key | Action | Info | |-----|--------|-------------| | `` `` | 確認 | | | `` `` | 閉じる/キャンセル | | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_ko.md000066400000000000000000000603421500612110400224540ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit 키 바인딩 _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ ## 글로벌 키 바인딩 | Key | Action | Info | |-----|--------|-------------| | `` `` | 최근에 사용한 저장소로 전환 | | | `` (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | | | `` (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | | | `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기 | Increase the amount of the context shown around changes in the diff view. | | `` { `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트 크기 줄이기 | Decrease the amount of the context shown around changes in the diff view. | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | | `` `` | 커스텀 Patch 옵션 보기 | | | `` m `` | View merge/rebase options | View options to abort/continue/skip the current merge/rebase. | | `` R `` | 새로고침 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | 다음 스크린 모드 (normal/half/fullscreen) | | | `` _ `` | 이전 스크린 모드 | | | `` ? `` | 매뉴 열기 | | | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q `` | 종료 | | | `` `` | 취소 | | | `` `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | Toggle whether or not whitespace changes are shown in the diff view. | | `` z `` | 되돌리기 (reflog) (실험적) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | | `` `` | 다시 실행 (reflog) (실험적) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | ## List panel navigation | Key | Action | Info | |-----|--------|-------------| | `` , `` | 이전 페이지 | | | `` . `` | 다음 페이지 | | | `` < () `` | 맨 위로 스크롤 | | | `` > () `` | 맨 아래로 스크롤 | | | `` v `` | 드래그 선택 전환 | | | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | 검색 시작 | | | `` H `` | 우 스크롤 | | | `` L `` | 좌 스크롤 | | | `` ] `` | 이전 탭 | | | `` [ `` | 다음 탭 | | ## Reflog | Key | Action | Info | |-----|--------|-------------| | `` `` | 커밋 해시를 클립보드에 복사 | | | `` `` | 체크아웃 | Checkout the selected commit as a detached HEAD. | | `` y `` | 커밋 attribute 복사 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (copied) commits selection | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Secondary | Key | Action | Info | |-----|--------|-------------| | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 검색 시작 | | ## Stash | Key | Action | Info | |-----|--------|-------------| | `` `` | 적용 | Apply the stash entry to your working directory. | | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Drop | Remove the stash entry from the stash list. | | `` n `` | 새 브랜치 생성 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | | `` r `` | Rename stash | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Sub-commits | Key | Action | Info | |-----|--------|-------------| | `` `` | 커밋 해시를 클립보드에 복사 | | | `` `` | 체크아웃 | Checkout the selected commit as a detached HEAD. | | `` y `` | 커밋 attribute 복사 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (copied) commits selection | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | | `` w `` | View worktree options | | | `` / `` | 검색 시작 | | ## Worktrees | Key | Action | Info | |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | | `` o `` | Open in editor | | | `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | Filter the current view by text | | ## 메뉴 | Key | Action | Info | |-----|--------|-------------| | `` `` | 실행 | | | `` `` | 닫기 | | | `` / `` | Filter the current view by text | | ## 메인 패널 (Merging) | Key | Action | Info | |-----|--------|-------------| | `` `` | Pick hunk | | | `` b `` | Pick all hunks | | | `` `` | 이전 hunk를 선택 | | | `` `` | 다음 hunk를 선택 | | | `` `` | 이전 충돌을 선택 | | | `` `` | 다음 충돌을 선택 | | | `` z `` | 되돌리기 | Undo last merge conflict resolution. | | `` e `` | 파일 편집 | Open file in external editor. | | `` o `` | 파일 닫기 | Open file in default application. | | `` M `` | Git mergetool를 열기 | Run `git mergetool`. | | `` `` | 파일 목록으로 돌아가기 | | ## 메인 패널 (Normal) | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | 아래로 스크롤 | | | `` mouse wheel up (fn+down) `` | 위로 스크롤 | | | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 검색 시작 | | ## 메인 패널 (Patch Building) | Key | Action | Info | |-----|--------|-------------| | `` `` | 이전 hunk를 선택 | | | `` `` | 다음 hunk를 선택 | | | `` v `` | 드래그 선택 전환 | | | `` a `` | Toggle select hunk | Toggle hunk selection mode. | | `` `` | 선택한 텍스트를 클립보드에 복사 | | | `` o `` | 파일 닫기 | Open file in default application. | | `` e `` | 파일 편집 | Open file in external editor. | | `` `` | Line(s)을 패치에 추가/삭제 | | | `` `` | Exit custom patch builder | | | `` / `` | 검색 시작 | | ## 메인 패널 (Staging) | Key | Action | Info | |-----|--------|-------------| | `` `` | 이전 hunk를 선택 | | | `` `` | 다음 hunk를 선택 | | | `` v `` | 드래그 선택 전환 | | | `` a `` | Toggle select hunk | Toggle hunk selection mode. | | `` `` | 선택한 텍스트를 클립보드에 복사 | | | `` `` | Staged 전환 | 선택한 행을 staged / unstaged | | `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | 파일 닫기 | Open file in default application. | | `` e `` | 파일 편집 | Open file in external editor. | | `` `` | 파일 목록으로 돌아가기 | | | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | | `` c `` | 커밋 변경내용 | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | | `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | 검색 시작 | | ## 브랜치 | Key | Action | Info | |-----|--------|-------------| | `` `` | 브랜치명을 클립보드에 복사 | | | `` i `` | Git-flow 옵션 보기 | | | `` `` | 체크아웃 | Checkout selected item. | | `` n `` | 새 브랜치 생성 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | 풀 리퀘스트 생성 | | | `` O `` | 풀 리퀘스트 생성 옵션 | | | `` `` | 풀 리퀘스트 URL을 클립보드에 복사 | | | `` c `` | 이름으로 체크아웃 | Checkout by name. In the input box you can enter '-' to switch to the last branch. | | `` F `` | 강제 체크아웃 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | 삭제 | View delete options for local/remote branch. | | `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. | | `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. | | `` T `` | 태그를 생성 | | | `` s `` | Sort order | | | `` g `` | View reset options | | | `` R `` | 브랜치 이름 변경 | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 상태 | Key | Action | Info | |-----|--------|-------------| | `` o `` | 설정 파일 열기 | Open file in default application. | | `` e `` | 설정 파일 수정 | Open file in external editor. | | `` u `` | 업데이트 확인 | | | `` `` | 최근에 사용한 저장소로 전환 | | | `` a `` | Show/cycle all branch logs | | ## 서브모듈 | Key | Action | Info | |-----|--------|-------------| | `` `` | 서브모듈 이름을 클립보드에 복사 | | | `` `` | Enter | 서브모듈 열기 | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | 서브모듈 업데이트 | | `` n `` | 새로운 서브모듈 추가 | | | `` e `` | 서브모듈의 URL을 수정 | | | `` i `` | Initialize | 서브모듈 초기화 | | `` b `` | View bulk submodule options | | | `` / `` | Filter the current view by text | | ## 원격 | Key | Action | Info | |-----|--------|-------------| | `` `` | View branches | | | `` n `` | 새로운 Remote 추가 | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Remote를 수정 | | `` f `` | Fetch | 원격을 업데이트 | | `` / `` | Filter the current view by text | | ## 원격 브랜치 | Key | Action | Info | |-----|--------|-------------| | `` `` | 브랜치명을 클립보드에 복사 | | | `` `` | 체크아웃 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 새 브랜치 생성 | | | `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. | | `` d `` | 삭제 | Delete the remote branch from the remote. | | `` u `` | Set as upstream | Set the selected remote branch as the upstream of the checked-out branch. | | `` s `` | Sort order | | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 커밋 | Key | Action | Info | |-----|--------|-------------| | `` `` | 커밋 해시를 클립보드에 복사 | | | `` `` | Reset cherry-picked (copied) commits selection | | | `` b `` | Bisect 옵션 보기 | | | `` s `` | 스쿼시 | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. | | `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. | | `` r `` | 커밋메시지 변경 | Reword the selected commit's message. | | `` R `` | 에디터에서 커밋메시지 수정 | | | `` d `` | 커밋 삭제 | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. | | `` e `` | Edit (start interactive rebase) | 커밋을 편집 | | `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit. If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | Pick | Pick commit (when mid-rebase) | | `` F `` | Create fixup commit | Create fixup commit for this commit | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits above selected commit (autosquash) | | `` `` | 커밋을 1개 아래로 이동 | | | `` `` | 커밋을 1개 위로 이동 | | | `` V `` | 커밋을 붙여넣기 (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Amend commit with staged changes | | `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | 로그 메뉴 열기 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` `` | 체크아웃 | Checkout the selected commit as a detached HEAD. | | `` y `` | 커밋 attribute 복사 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | | `` w `` | View worktree options | | | `` / `` | 검색 시작 | | ## 커밋 파일 | Key | Action | Info | |-----|--------|-------------| | `` `` | 파일명을 클립보드에 복사 | | | `` y `` | 클립보드에 복사 | | | `` c `` | 체크아웃 | Checkout file | | `` d `` | Remove | Discard this commit's changes to this file | | `` o `` | 파일 닫기 | Open file in default application. | | `` e `` | Edit | Open file in external editor. | | `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files included in patch | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter file to add selected lines to the patch (or toggle directory collapsed) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | | `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | 검색 시작 | | ## 커밋메시지 | Key | Action | Info | |-----|--------|-------------| | `` `` | 확인 | | | `` `` | 닫기 | | ## 태그 | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | 체크아웃 | Checkout the selected tag as a detached HEAD. | | `` n `` | 태그를 생성 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | 삭제 | View delete options for local/remote tag. | | `` P `` | 태그를 push | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | 초기화 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 파일 | Key | Action | Info | |-----|--------|-------------| | `` `` | 파일명을 클립보드에 복사 | | | `` `` | Staged 전환 | Toggle staged for selected file. | | `` `` | 파일을 필터하기 (Staged/unstaged) | | | `` y `` | 클립보드에 복사 | | | `` c `` | 커밋 변경내용 | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | | `` A `` | 마지맛 커밋 수정 | | | `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | Edit | Open file in external editor. | | `` o `` | 파일 닫기 | Open file in default application. | | `` i `` | Ignore file | | | `` r `` | 파일 새로고침 | | | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | Stash 옵션 보기 | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | 모든 변경을 Staged/unstaged으로 전환 | Toggle staged/unstaged for all files in working tree. | | `` `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | | `` d `` | View 'discard changes' options | View options for discarding changes to the selected file. | | `` g `` | View upstream reset options | | | `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` `` | Open external diff tool (git difftool) | | | `` M `` | Git mergetool를 열기 | Run `git mergetool`. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | 검색 시작 | | ## 확인 패널 | Key | Action | Info | |-----|--------|-------------| | `` `` | 확인 | | | `` `` | 닫기/취소 | | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_nl.md000066400000000000000000000572021500612110400224550ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit Sneltoetsen _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ ## Globale sneltoetsen | Key | Action | Info | |-----|--------|-------------| | `` `` | Wissel naar een recente repo | | | `` (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | | | `` (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | | | `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view. | | `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view. | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | | `` `` | Bekijk aangepaste patch opties | | | `` m `` | Bekijk merge/rebase opties | View options to abort/continue/skip the current merge/rebase. | | `` R `` | Verversen | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Volgende scherm modus (normaal/half/groot) | | | `` _ `` | Vorige scherm modus | | | `` ? `` | Open menu | | | `` `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | Open diff menu | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | Open diff menu | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q `` | Quit | | | `` `` | Annuleren | | | `` `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view. | | `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | | `` `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | ## Lijstpaneel navigatie | Key | Action | Info | |-----|--------|-------------| | `` , `` | Vorige pagina | | | `` . `` | Volgende pagina | | | `` < () `` | Scroll naar boven | | | `` > () `` | Scroll naar beneden | | | `` v `` | Toggle drag selecteer | | | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | Start met zoeken | | | `` H `` | Scroll left | | | `` L `` | Scroll right | | | `` ] `` | Volgende tabblad | | | `` [ `` | Vorige tabblad | | ## Bestanden | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer de bestandsnaam naar het klembord | | | `` `` | Toggle staged | Toggle staged for selected file. | | `` `` | Filter files by status | | | `` y `` | Copy to clipboard | | | `` c `` | Commit veranderingen | Commit staged changes. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` A `` | Wijzig laatste commit | | | `` C `` | Commit veranderingen met de git editor | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | Edit | Open file in external editor. | | `` o `` | Open bestand | Open file in default application. | | `` i `` | Ignore or exclude file | | | `` r `` | Refresh bestanden | | | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | Bekijk stash opties | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | Toggle staged alle | Toggle staged/unstaged for all files in working tree. | | `` `` | Stage individuele hunks/lijnen | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | | `` d `` | Bekijk 'veranderingen ongedaan maken' opties | View options for discarding changes to the selected file. | | `` g `` | Bekijk upstream reset opties | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` `` | Open external diff tool (git difftool) | | | `` M `` | Open external merge tool | Run `git mergetool`. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | Start met zoeken | | ## Bevestigingspaneel | Key | Action | Info | |-----|--------|-------------| | `` `` | Bevestig | | | `` `` | Sluiten | | ## Branches | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer branch name naar klembord | | | `` i `` | Laat git-flow opties zien | | | `` `` | Uitchecken | Checkout selected item. | | `` n `` | Nieuwe branch | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | Maak een pull-request | | | `` O `` | Bekijk opties voor pull-aanvraag | | | `` `` | Kopieer de URL van het pull-verzoek naar het klembord | | | `` c `` | Uitchecken bij naam | Checkout by name. In the input box you can enter '-' to switch to the last branch. | | `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Delete | View delete options for local/remote branch. | | `` r `` | Rebase branch | Rebase the checked-out branch onto the selected branch. | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. | | `` T `` | Creëer tag | | | `` s `` | Sort order | | | `` g `` | Bekijk reset opties | | | `` R `` | Hernoem branch | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Commit bericht | Key | Action | Info | |-----|--------|-------------| | `` `` | Bevestig | | | `` `` | Sluiten | | ## Commit bestanden | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer de bestandsnaam naar het klembord | | | `` y `` | Copy to clipboard | | | `` c `` | Uitchecken | Bestand uitchecken | | `` d `` | Remove | Uitsluit deze commit zijn veranderingen aan dit bestand | | `` o `` | Open bestand | Open file in default application. | | `` e `` | Edit | Open file in external editor. | | `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | Start met zoeken | | ## Commits | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer commit hash naar klembord | | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | | `` b `` | View bisect options | | | `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. | | `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. | | `` r `` | Hernoem commit | Reword the selected commit's message. | | `` R `` | Hernoem commit met editor | | | `` d `` | Verwijder commit | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. | | `` e `` | Edit (start interactive rebase) | Wijzig commit | | `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit. If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | Pick | Kies commit (wanneer midden in rebase) | | `` F `` | Creëer fixup commit | Creëer fixup commit | | `` S `` | Apply fixup commits | Squash bovenstaande commits | | `` `` | Verplaats commit 1 naar beneden | | | `` `` | Verplaats commit 1 naar boven | | | `` V `` | Plak commits (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Wijzig commit met staged veranderingen | | `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | | `` w `` | View worktree options | | | `` / `` | Start met zoeken | | ## Menu | Key | Action | Info | |-----|--------|-------------| | `` `` | Uitvoeren | | | `` `` | Sluiten | | | `` / `` | Filter the current view by text | | ## Mergen | Key | Action | Info | |-----|--------|-------------| | `` `` | Kies stuk | | | `` b `` | Kies beide stukken | | | `` `` | Selecteer bovenste hunk | | | `` `` | Selecteer onderste hunk | | | `` `` | Selecteer voorgaand conflict | | | `` `` | Selecteer volgende conflict | | | `` z `` | Ongedaan maken | Undo last merge conflict resolution. | | `` e `` | Verander bestand | Open file in external editor. | | `` o `` | Open bestand | Open file in default application. | | `` M `` | Open external merge tool | Run `git mergetool`. | | `` `` | Ga terug naar het bestanden paneel | | ## Normaal | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | Scroll omlaag | | | `` mouse wheel up (fn+down) `` | Scroll omhoog | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Start met zoeken | | ## Patch bouwen | Key | Action | Info | |-----|--------|-------------| | `` `` | Selecteer de vorige hunk | | | `` `` | Selecteer de volgende hunk | | | `` v `` | Toggle drag selecteer | | | `` a `` | Toggle selecteer hunk | Toggle hunk selection mode. | | `` `` | Copy selected text to clipboard | | | `` o `` | Open bestand | Open file in default application. | | `` e `` | Verander bestand | Open file in external editor. | | `` `` | Voeg toe/verwijder lijn(en) in patch | | | `` `` | Sluit lijn-bij-lijn modus | | | `` / `` | Start met zoeken | | ## Reflog | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer commit hash naar klembord | | | `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remote branches | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer branch name naar klembord | | | `` `` | Uitchecken | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | Nieuwe branch | | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Rebase branch | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | | `` u `` | Set as upstream | Stel in als upstream van uitgecheckte branch | | `` s `` | Sort order | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remotes | Key | Action | Info | |-----|--------|-------------| | `` `` | View branches | | | `` n `` | Voeg een nieuwe remote toe | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Wijzig remote | | `` f `` | Fetch | Fetch remote | | `` / `` | Filter the current view by text | | ## Secondary | Key | Action | Info | |-----|--------|-------------| | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Start met zoeken | | ## Staging | Key | Action | Info | |-----|--------|-------------| | `` `` | Selecteer de vorige hunk | | | `` `` | Selecteer de volgende hunk | | | `` v `` | Toggle drag selecteer | | | `` a `` | Toggle selecteer hunk | Toggle hunk selection mode. | | `` `` | Copy selected text to clipboard | | | `` `` | Toggle staged | Toggle lijnen staged / unstaged | | `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | Open bestand | Open file in default application. | | `` e `` | Verander bestand | Open file in external editor. | | `` `` | Ga terug naar het bestanden paneel | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | | `` c `` | Commit veranderingen | Commit staged changes. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` C `` | Commit veranderingen met de git editor | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | Start met zoeken | | ## Stash | Key | Action | Info | |-----|--------|-------------| | `` `` | Toepassen | Apply the stash entry to your working directory. | | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Laten vallen | Remove the stash entry from the stash list. | | `` n `` | Nieuwe branch | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | | `` r `` | Rename stash | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Status | Key | Action | Info | |-----|--------|-------------| | `` o `` | Open config bestand | Open file in default application. | | `` e `` | Verander config bestand | Open file in external editor. | | `` u `` | Check voor updates | | | `` `` | Wissel naar een recente repo | | | `` a `` | Show/cycle all branch logs | | ## Sub-commits | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer commit hash naar klembord | | | `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | | `` w `` | View worktree options | | | `` / `` | Start met zoeken | | ## Submodules | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer submodule naam naar klembord | | | `` `` | Enter | Enter submodule | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Update selected submodule. | | `` n `` | Voeg nieuwe submodule toe | | | `` e `` | Update submodule URL | | | `` i `` | Initialize | Initialiseer submodule | | `` b `` | Bekijk bulk submodule opties | | | `` / `` | Filter the current view by text | | ## Tags | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | Uitchecken | Checkout the selected tag as a detached HEAD. | | `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | Delete | View delete options for local/remote tag. | | `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Worktrees | Key | Action | Info | |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | | `` o `` | Open in editor | | | `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | Filter the current view by text | | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_pl.md000066400000000000000000000643441500612110400224640ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit Skróty klawiszowe _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ ## Globalne skróty klawiszowe | Key | Action | Info | |-----|--------|-------------| | `` `` | Przełącz na ostatnie repozytorium | | | `` (fn+up/shift+k) `` | Przewiń główne okno w górę | | | `` (fn+down/shift+j) `` | Przewiń główne okno w dół | | | `` @ `` | Pokaż opcje dziennika poleceń | Pokaż opcje dla dziennika poleceń, np. pokazywanie/ukrywanie dziennika poleceń i skupienie na dzienniku poleceń. | | `` P `` | Wypchnij | Wypchnij bieżącą gałąź do jej gałęzi nadrzędnej. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. | | `` p `` | Pociągnij | Pociągnij zmiany z zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | Zwiększ rozmiar kontekstu w widoku różnic | Zwiększ ilość kontekstu pokazywanego wokół zmian w widoku różnic. | | `` { `` | Zmniejsz rozmiar kontekstu w widoku różnic | Zmniejsz ilość kontekstu pokazywanego wokół zmian w widoku różnic. | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | | `` `` | Wyświetl opcje niestandardowej łatki | | | `` m `` | Pokaż opcje scalania/rebase | Pokaż opcje do przerwania/kontynuowania/pominięcia bieżącego scalania/rebase. | | `` R `` | Odśwież | Odśwież stan git (tj. uruchom `git status`, `git branch`, itp. w tle, aby zaktualizować zawartość paneli). To nie uruchamia `git fetch`. | | `` + `` | Następny tryb ekranu (normalny/półpełny/pełnoekranowy) | | | `` _ `` | Poprzedni tryb ekranu | | | `` ? `` | Otwórz menu przypisań klawiszy | | | `` `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. | | `` W `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | | `` `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | | `` q `` | Wyjdź | | | `` `` | Anuluj | | | `` `` | Przełącz białe znaki | Przełącz czy zmiany białych znaków są pokazywane w widoku różnic. | | `` z `` | Cofnij | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby cofnąć ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | | `` `` | Ponów | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby ponowić ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | ## Nawigacja panelu listy | Key | Action | Info | |-----|--------|-------------| | `` , `` | Poprzednia strona | | | `` . `` | Następna strona | | | `` < () `` | Przewiń do góry | | | `` > () `` | Przewiń do dołu | | | `` v `` | Przełącz zaznaczenie zakresu | | | `` `` | Zaznacz zakres w dół | | | `` `` | Zaznacz zakres w górę | | | `` / `` | Szukaj w bieżącym widoku po tekście | | | `` H `` | Przewiń w lewo | | | `` L `` | Przewiń w prawo | | | `` ] `` | Następna zakładka | | | `` [ `` | Poprzednia zakładka | | ## Commity | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopiuj hash commita do schowka | | | `` `` | Resetuj wybrane (cherry-picked) commity | | | `` b `` | Zobacz opcje bisect | | | `` s `` | Scal | Scal wybrany commit z commitami poniżej. Wiadomość wybranego commita zostanie dołączona do commita poniżej. | | `` f `` | Poprawka | Włącz wybrany commit do commita poniżej. Podobnie do fixup, ale wiadomość wybranego commita zostanie odrzucona. | | `` r `` | Przeformułuj | Przeformułuj wiadomość wybranego commita. | | `` R `` | Przeformułuj za pomocą edytora | | | `` d `` | Usuń | Usuń wybrany commit. To usunie commit z gałęzi za pomocą rebazowania. Jeśli commit wprowadza zmiany, od których zależą późniejsze commity, być może będziesz musiał rozwiązać konflikty scalania. | | `` e `` | Edytuj (rozpocznij interaktywne rebazowanie) | Edytuj wybrany commit. Użyj tego, aby rozpocząć interaktywne rebazowanie od wybranego commita. Podczas trwania rebazowania, to oznaczy wybrany commit do edycji, co oznacza, że po kontynuacji rebazowania, rebazowanie zostanie wstrzymane na wybranym commicie, aby umożliwić wprowadzenie zmian. | | `` i `` | Rozpocznij interaktywny rebase | Rozpocznij interaktywny rebase dla commitów na twoim branchu. To będzie zawierać wszystkie commity od HEAD do pierwszego commita scalenia lub commita głównego brancha. Jeśli chcesz zamiast tego rozpocząć interaktywny rebase od wybranego commita, naciśnij `e`. | | `` p `` | Wybierz | Oznacz wybrany commit do wybrania (podczas rebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji rebazowania. | | `` F `` | Utwórz commit fixup | Utwórz commit 'fixup!' dla wybranego commita. Później możesz nacisnąć `S` na tym samym commicie, aby zastosować wszystkie powyższe commity fixup. | | `` S `` | Zastosuj commity fixup | Scal wszystkie commity 'fixup!', albo powyżej wybranego commita, albo wszystkie w bieżącej gałęzi (autosquash). | | `` `` | Przesuń commit w dół | | | `` `` | Przesuń commit w górę | | | `` V `` | Wklej (cherry-pick) | | | `` B `` | Oznacz jako bazowy commit dla rebase | Wybierz bazowy commit dla następnego rebase. Kiedy robisz rebase na branch, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`. | | `` A `` | Popraw | Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą rebazowania. | | `` a `` | Popraw atrybut commita | Ustaw/Resetuj autora commita lub ustaw współautora. | | `` t `` | Cofnij | Utwórz commit cofający dla wybranego commita, który stosuje zmiany wybranego commita w odwrotnej kolejności. | | `` T `` | Otaguj commit | Utwórz nowy tag wskazujący na wybrany commit. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | | `` `` | Zobacz opcje logów | Zobacz opcje dla logów commitów, np. zmiana kolejności sortowania, ukrywanie grafu gita, pokazywanie całego grafu gita. | | `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | | `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | | `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Dodatkowy | Key | Action | Info | |-----|--------|-------------| | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Drzewa pracy | Key | Action | Info | |-----|--------|-------------| | `` n `` | Nowe drzewo pracy | | | `` `` | Przełącz | Przełącz do wybranego drzewa pracy. | | `` o `` | Otwórz w edytorze | | | `` d `` | Usuń | Usuń wybrane drzewo pracy. To usunie zarówno katalog drzewa pracy, jak i metadane o drzewie pracy w katalogu .git. | | `` / `` | Filtruj bieżący widok po tekście | | ## Główny panel (budowanie łatki) | Key | Action | Info | |-----|--------|-------------| | `` `` | Idź do poprzedniego fragmentu | | | `` `` | Idź do następnego fragmentu | | | `` v `` | Przełącz zaznaczenie zakresu | | | `` a `` | Zaznacz fragment | Przełącz tryb zaznaczania fragmentu. | | `` `` | Kopiuj zaznaczony tekst do schowka | | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | | `` `` | Przełącz linie w łatce | | | `` `` | Wyjdź z budowniczego niestandardowej łatki | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Lokalne gałęzie | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopiuj nazwę gałęzi do schowka | | | `` i `` | Pokaż opcje git-flow | | | `` `` | Przełącz | Przełącz wybrany element. | | `` n `` | Nowa gałąź | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | Utwórz żądanie ściągnięcia | | | `` O `` | Zobacz opcje tworzenia pull requesta | | | `` `` | Kopiuj adres URL żądania ściągnięcia do schowka | | | `` c `` | Przełącz według nazwy | Przełącz według nazwy. W polu wprowadzania możesz wpisać '-' aby przełączyć się na ostatnią gałąź. | | `` F `` | Wymuś przełączenie | Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnej/odległej gałęzi. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | | `` f `` | Szybkie przewijanie | Szybkie przewijanie wybranej gałęzi z jej źródła. | | `` T `` | Nowy tag | | | `` s `` | Kolejność sortowania | | | `` g `` | Reset | | | `` R `` | Zmień nazwę gałęzi | | | `` u `` | Pokaż opcje upstream | Pokaż opcje dotyczące upstream gałęzi, np. ustawianie/usuwanie upstream i resetowanie do upstream. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | | `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Menu | Key | Action | Info | |-----|--------|-------------| | `` `` | Wykonaj | | | `` `` | Zamknij | | | `` / `` | Filtruj bieżący widok po tekście | | ## Panel główny (normalny) | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | Przewiń w dół | | | `` mouse wheel up (fn+down) `` | Przewiń w górę | | | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Panel główny (scalanie) | Key | Action | Info | |-----|--------|-------------| | `` `` | Wybierz fragment | | | `` b `` | Wybierz wszystkie fragmenty | | | `` `` | Poprzedni fragment | | | `` `` | Następny fragment | | | `` `` | Poprzedni konflikt | | | `` `` | Następny konflikt | | | `` z `` | Cofnij | Cofnij ostatnie rozwiązanie konfliktu scalania. | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` M `` | Otwórz zewnętrzne narzędzie scalania | Uruchom `git mergetool`. | | `` `` | Wróć do panelu plików | | ## Panel główny (zatwierdzanie) | Key | Action | Info | |-----|--------|-------------| | `` `` | Idź do poprzedniego fragmentu | | | `` `` | Idź do następnego fragmentu | | | `` v `` | Przełącz zaznaczenie zakresu | | | `` a `` | Zaznacz fragment | Przełącz tryb zaznaczania fragmentu. | | `` `` | Kopiuj zaznaczony tekst do schowka | | | `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | | `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | | `` `` | Wróć do panelu plików | | | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. | | `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | | `` w `` | Zatwierdź zmiany bez hooka pre-commit | | | `` C `` | Zatwierdź zmiany używając edytora git | | | `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Panel potwierdzenia | Key | Action | Info | |-----|--------|-------------| | `` `` | Potwierdź | | | `` `` | Zamknij/Anuluj | | ## Pliki | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopiuj ścieżkę do schowka | | | `` `` | Zatwierdź | Przełącz zatwierdzenie dla wybranego pliku. | | `` `` | Filtruj pliki według statusu | | | `` y `` | Kopiuj do schowka | | | `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | | `` w `` | Zatwierdź zmiany bez hooka pre-commit | | | `` A `` | Popraw ostatni commit | | | `` C `` | Zatwierdź zmiany używając edytora git | | | `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | | `` e `` | Edytuj | Otwórz plik w zewnętrznym edytorze. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` i `` | Ignoruj lub wyklucz plik | | | `` r `` | Odśwież pliki | | | `` s `` | Schowaj | Schowaj wszystkie zmiany. Dla innych wariantów schowania, użyj klawisza wyświetlania opcji schowka. | | `` S `` | Wyświetl opcje schowka | Wyświetl opcje schowka (np. schowaj wszystko, schowaj zatwierdzone, schowaj niezatwierdzone). | | `` a `` | Zatwierdź wszystko | Przełącz zatwierdzenie/odznaczenie dla wszystkich plików w drzewie roboczym. | | `` `` | Zatwierdź linie / Zwiń katalog | Jeśli wybrany element jest plikiem, skup się na widoku zatwierdzania, aby móc zatwierdzać poszczególne fragmenty/linie. Jeśli wybrany element jest katalogiem, zwiń/rozwiń go. | | `` d `` | Odrzuć | Wyświetl opcje odrzucania zmian w wybranym pliku. | | `` g `` | Pokaż opcje resetowania do upstream | | | `` D `` | Reset | Wyświetl opcje resetu dla drzewa roboczego (np. zniszczenie drzewa roboczego). | | `` ` `` | Przełącz widok drzewa plików | Przełącz widok plików między płaskim a drzewem. Płaski układ pokazuje wszystkie ścieżki plików na jednej liście, układ drzewa grupuje pliki według katalogów. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` M `` | Otwórz zewnętrzne narzędzie scalania | Uruchom `git mergetool`. | | `` f `` | Pobierz | Pobierz zmiany ze zdalnego serwera. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Pliki commita | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopiuj ścieżkę do schowka | | | `` y `` | Kopiuj do schowka | | | `` c `` | Przełącz | Przełącz plik. Zastępuje plik w twoim drzewie roboczym wersją z wybranego commita. | | `` d `` | Usuń | Odrzuć zmiany w tym pliku z tego commita. Uruchamia interaktywny rebase w tle, więc możesz otrzymać konflikt scalania, jeśli późniejszy commit również zmienia ten plik. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj | Otwórz plik w zewnętrznym edytorze. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` `` | Przełącz plik włączony w łatkę | Przełącz, czy plik jest włączony w niestandardową łatkę. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Przełącz wszystkie pliki | Dodaj/usuń wszystkie pliki commita do niestandardowej łatki. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Wejdź do pliku / Przełącz zwiń katalog | Jeśli plik jest wybrany, wejdź do pliku, aby móc dodawać/usuwać poszczególne linie do niestandardowej łatki. Jeśli wybrany jest katalog, przełącz katalog. | | `` ` `` | Przełącz widok drzewa plików | Przełącz widok plików między płaskim a drzewem. Płaski układ pokazuje wszystkie ścieżki plików na jednej liście, układ drzewa grupuje pliki według katalogów. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Podsumowanie commita | Key | Action | Info | |-----|--------|-------------| | `` `` | Potwierdź | | | `` `` | Zamknij | | ## Reflog | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopiuj hash commita do schowka | | | `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | | `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Resetuj wybrane (cherry-picked) commity | | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | | `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Schowek | Key | Action | Info | |-----|--------|-------------| | `` `` | Zastosuj | Zastosuj wpis schowka do katalogu roboczego. | | `` g `` | Wyciągnij | Zastosuj wpis schowka do katalogu roboczego i usuń wpis schowka. | | `` d `` | Usuń | Usuń wpis schowka z listy schowka. | | `` n `` | Nowa gałąź | Utwórz nową gałąź z wybranego wpisu schowka. Działa poprzez przełączenie git na commit, na którym wpis schowka został utworzony, tworzenie nowej gałęzi z tego commita, a następnie zastosowanie wpisu schowka do nowej gałęzi jako dodatkowego commita. | | `` r `` | Zmień nazwę schowka | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | | `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Status | Key | Action | Info | |-----|--------|-------------| | `` o `` | Otwórz plik konfiguracyjny | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. | | `` u `` | Sprawdź aktualizacje | | | `` `` | Przełącz na ostatnie repozytorium | | | `` a `` | Show/cycle all branch logs | | ## Sub-commity | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopiuj hash commita do schowka | | | `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | | `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Resetuj wybrane (cherry-picked) commity | | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | | `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Submoduły | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopiuj nazwę submodułu do schowka | | | `` `` | Wejdź | Wejdź do submodułu. Po wejściu do submodułu możesz nacisnąć ``, aby wrócić do repozytorium nadrzędnego. | | `` d `` | Usuń | Usuń wybrany submoduł i odpowiadający mu katalog. | | `` u `` | Aktualizuj | Aktualizuj wybrany submoduł. | | `` n `` | Nowy submoduł | | | `` e `` | Zaktualizuj URL submodułu | | | `` i `` | Zainicjuj | Zainicjuj wybrany submoduł, aby przygotować do pobrania. Prawdopodobnie chcesz to kontynuować, wywołując akcję 'update', aby pobrać submoduł. | | `` b `` | Pokaż opcje masowych operacji na submodułach | | | `` / `` | Filtruj bieżący widok po tekście | | ## Tagi | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | Przełącz | Przełącz wybrany tag jako odłączoną głowę (detached HEAD). | | `` n `` | Nowy tag | Utwórz nowy tag z bieżącego commita. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnego/odległego tagu. | | `` P `` | Wyślij tag | Wyślij wybrany tag do zdalnego. Zostaniesz poproszony o wybranie zdalnego. | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | | `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Zdalne | Key | Action | Info | |-----|--------|-------------| | `` `` | Wyświetl gałęzie | | | `` n `` | Nowy zdalny | | | `` d `` | Usuń | Usuń wybrany zdalny. Wszelkie lokalne gałęzie śledzące gałąź zdalną z tego zdalnego nie zostaną dotknięte. | | `` e `` | Edytuj | Edytuj nazwę lub URL wybranego zdalnego. | | `` f `` | Pobierz | Pobierz aktualizacje z zdalnego repozytorium. Pobiera nowe commity i gałęzie bez scalania ich z lokalnymi gałęziami. | | `` / `` | Filtruj bieżący widok po tekście | | ## Zdalne gałęzie | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopiuj nazwę gałęzi do schowka | | | `` `` | Przełącz | Przełącz na nową lokalną gałąź na podstawie wybranej gałęzi zdalnej. Nowa gałąź będzie śledzić gałąź zdalną. | | `` n `` | Nowa gałąź | | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. | | `` d `` | Usuń | Usuń gałąź zdalną ze zdalnego. | | `` u `` | Ustaw jako upstream | Ustaw wybraną gałąź zdalną jako upstream sprawdzonej gałęzi. | | `` s `` | Kolejność sortowania | | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | | `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_pt.md000066400000000000000000000640721500612110400224720ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit Keybindings _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ ## Combinações globais de teclas | Key | Action | Info | |-----|--------|-------------| | `` `` | Mudar para um repositório recente | | | `` (fn+up/shift+k) `` | Rolar janela principal para cima | | | `` (fn+down/shift+j) `` | Rolar a janela principal para baixo | | | `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Empurre (Push) | Faça push do branch atual para o seu branch upstream. Se nenhum upstream estiver configurado, você será solicitado a configurar um branch a montante. | | `` p `` | Puxar (Pull) | Puxe alterações do controle remoto para o ramo atual. Se nenhum upstream estiver configurado, será solicitado configurar um ramo a montante. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view. | | `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view. | | `` : `` | Executar comando da shell | Traga um prompt onde você pode digitar um comando shell para executar. | | `` `` | Ver opções de patch personalizadas | | | `` m `` | Ver opções de mesclar/rebase | Ver opções para abortar/continuar/pular o merge/rebase atual. | | `` R `` | Atualizar | Atualize o estado do git (ou seja, execute `git status`, `git branch`, etc em segundo plano para atualizar o conteúdo de painéis). Isso não executa `git fetch`. | | `` + `` | Next screen mode (normal/half/fullscreen) | | | `` _ `` | Prev screen mode | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q `` | Sair | | | `` `` | Cancelar | | | `` `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view. | | `` z `` | Desfazer | O reflog será usado para determinar qual comando git para executar para desfazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | | `` `` | Refazer | O reflog será usado para determinar qual comando git para executar para refazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | ## List panel navigation | Key | Action | Info | |-----|--------|-------------| | `` , `` | Previous page | | | `` . `` | Next page | | | `` < () `` | Scroll to top | | | `` > () `` | Scroll to bottom | | | `` v `` | Toggle range select | | | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | Search the current view by text | | | `` H `` | Rolar à esquerda | | | `` L `` | Scroll para a direita | | | `` ] `` | Next tab | | | `` [ `` | Previous tab | | ## Arquivos | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy path to clipboard | | | `` `` | Etapa | Alternar para staging para o arquivo selecionado. | | `` `` | Filtrar arquivos por status | | | `` y `` | Copy to clipboard | | | `` c `` | Commit | Submeter mudanças em staging | | `` w `` | Fazer commit de alterações sem pré-commit | | | `` A `` | Alterar último commit | | | `` C `` | Enviar alteração usando um editor Git | | | `` `` | Encontrar commit da base para consertar | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado Veja a documentação: | | `` e `` | Editar | Abrir arquivo no editor externo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | | `` i `` | Ignore or exclude file | | | `` r `` | Atualizar arquivos | | | `` s `` | Stash | Stash todas as alterações. Para outras variações de armazenamento, use a fixação de teclas de armazenamento. | | `` S `` | Ver opções de stash | Ver opções de stash (por exemplo, trash all, stash staged, stash unsttued). | | `` a `` | Stage completo | Alternar para todos os arquivos na árvore de trabalho | | `` `` | Stage lines / Colapso diretório | Se o item selecionado for um arquivo, o foco na exibição de preparo para o estágio de cenas/linhas individuais. Se o item selecionado for um diretório, recolher/expandi-lo. | | `` d `` | Descartar | Exibir opções para descartar alterações para o arquivo selecionado. | | `` g `` | View upstream reset options | | | `` D `` | Restaurar | Opções de redefinição de exibição para árvore de trabalho (por exemplo, nukando a árvore de trabalho). | | `` ` `` | Alternar exibição de árvore de arquivo | Alternar a visualização de arquivo entre layout plano e layout de árvore. Layout plano mostra todos os caminhos de arquivo em uma única lista, layout de árvore agrupa arquivos por diretório. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` M `` | Abrir ferramenta de merge externa | Execute `git mergetool`. | | `` f `` | Buscar | Buscar alterações do controle remoto. | | `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos | | `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo | | `` 0 `` | Focus main view | | | `` / `` | Search the current view by text | | ## Branches locais | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy branch name to clipboard | | | `` i `` | Show git-flow options | | | `` `` | Verificar | Checar item selecionado | | `` n `` | Nova branch | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | Create pull request | | | `` O `` | View create pull request options | | | `` `` | Copiar URL do pull request para área de transferência | | | `` c `` | Checar por nome | Checar por nome. Na caixa de entrada você pode inserir '-' para trocar para a última branch | | `` F `` | Forçar checagem | Forçar checagem da branch selecionada. Isso irá descartar todas as mudanças no seu diretório de trabalho antes cheque a branch selecionada | | `` d `` | Apagar | Ver opções de exclusão para a branch local/remoto. | | `` r `` | Refazer | Refazer a branch checada na branch selecionada | | `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) | | `` f `` | Avanço rápido | Encaminhamento rápido de branch selecionada a partir do upstream. | | `` T `` | New tag | | | `` s `` | Sort order | | | `` g `` | Restaurar | | | `` R `` | Rename branch | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Branches remotos | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy branch name to clipboard | | | `` `` | Verificar | Checar a nova branch baseada na brach remota selecionada, ou a branch remota como HEAD, desanexado | | `` n `` | Nova branch | | | `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) | | `` r `` | Refazer | Refazer a branch checada na branch selecionada | | `` d `` | Apagar | Excluir o branch remoto do controle remoto. | | `` u `` | Definir como upstream | Definir o ramo remoto selecionado como fluxo do branch check-out. | | `` s `` | Sort order | | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Commit arquivos | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy path to clipboard | | | `` y `` | Copy to clipboard | | | `` c `` | Verificar | Arquivo de check-out. Isso substitui o arquivo em sua árvore de trabalho com a versão do commit selecionado. | | `` d `` | Remover | Descartar as alterações desse commit para este arquivo. Isso executa uma rebase interativa em segundo plano, então você pode ter um conflito de merge se um commit posterior também alterar este arquivo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | | `` e `` | Editar | Abrir arquivo no editor externo. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` `` | Alternar entre o arquivo incluído no patch | Alternar se o arquivo está incluído no patch personalizado. Veja https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Alternar todos os arquivos | Adicionar/remover todos os arquivos de commit para atualização personalizada. Consulte https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Insira o arquivo / Alternar diretório recolhido | Se um arquivo estiver selecionado, insira o arquivo para que você possa adicionar/remover linhas individuais no patch personalizado. Se um diretório for selecionado, ative o diretório. | | `` ` `` | Alternar exibição de árvore de arquivo | Alternar a visualização de arquivo entre layout plano e layout de árvore. Layout plano mostra todos os caminhos de arquivo em uma única lista, layout de árvore agrupa arquivos por diretório. | | `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos | | `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo | | `` 0 `` | Focus main view | | | `` / `` | Search the current view by text | | ## Commits | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy commit hash to clipboard | | | `` `` | Reset copied (cherry-picked) commits selection | | | `` b `` | View bisect options | | | `` s `` | Squash | Squash o commit selecionado no commit abaixo dele. A mensagem do commit selecionado será anexada ao commit abaixo dele. | | `` f `` | Fixup | Faça o commit selecionado no commit abaixo dele. Semelhante para o squash, mas a mensagem do commit selecionado será descartada. | | `` r `` | Reword | Repetir a mensagem de submissão selecionada. | | `` R `` | Republicar com o editor | | | `` d `` | Descartar | Solte o commit selecionado. Isso irá remover o commit do branch através de uma rebase. Se o commit faz com que as alterações em commits posteriores dependem, você pode precisar resolver conflitos de merge. | | `` e `` | Editar (iniciar rebase interativa) | Editar o commit selecionado. Use isto para iniciar uma rebase interativa a partir do commit selecionado. Quando já estiver no meio da reconstrução, isto irá marcar o commit selecionado para edição, o que significa que ao continuar com a reformulação. a rebase irá pausar no commit selecionado para permitir que você faça alterações. | | `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit. If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | Escolher | Marque o commit selecionado para ser escolhido (quando meados da base). Isso significa que o commit será mantido ao continuar o rebase. | | `` F `` | Criar commit de correção | Crie o commit 'correção!' para o commit selecionado. Mais tarde, você pode pressionar `S` neste mesmo commit para aplicar todas os commits de correção acima. | | `` S `` | Aplicar commits de correções | Aplicar Squash all 'correção!', seja acima do commit selecionado, ou tudo no branch atual (autosquash). | | `` `` | Mover commit um para baixo | | | `` `` | Mover o commit um para cima | | | `` V `` | Colar (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Modificar | Alterar o commit com mudanças em sted. Se o commit selecionado for o commit HEAD, ele executará o `git commit --amend`. Caso contrário, o compromisso será alterado por meio de uma base de apoio. | | `` a `` | Alterar atributo de commit | Definir/Redefinir autor de submissão ou co-autor definido. | | `` t `` | Reverter | Crie um commit reverter para o commit selecionado, que aplica as alterações do commit selecionado em reverso. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` `` | Verificar | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Ver arquivos | | | `` w `` | View worktree options | | | `` / `` | Search the current view by text | | ## Confirmation panel | Key | Action | Info | |-----|--------|-------------| | `` `` | Confirmar | | | `` `` | Fechar/Cancelar | | ## Etiquetas | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | Verificar | Checar a tag selecionada como um HEAD, desanexado | | `` n `` | New tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | Apagar | Ver opções de exclusão para tag local/remoto. | | `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Menu | Key | Action | Info | |-----|--------|-------------| | `` `` | Executar | | | `` `` | Fechar | | | `` / `` | Filter the current view by text | | ## Painel Principal (Normal) | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | Rolar para baixo | | | `` mouse wheel up (fn+down) `` | Rolar para cima | | | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | ## Painel Principal (preparação) | Key | Action | Info | |-----|--------|-------------| | `` `` | Ir para o local anterior | | | `` `` | Ir para o próximo trecho | | | `` v `` | Toggle range select | | | `` a `` | Selecione o local | Ativa/desativa modo seleção de hunk | | `` `` | Copy selected text to clipboard | | | `` `` | Etapa | Ativar/desativar seleção em staged/unstaged | | `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` `` | Retornar ao painel de arquivos | | | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` E `` | Editar hunk | Editar o local selecionado no editor externo. | | `` c `` | Commit | Submeter mudanças em staging | | `` w `` | Fazer commit de alterações sem pré-commit | | | `` C `` | Enviar alteração usando um editor Git | | | `` `` | Encontrar commit da base para consertar | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado Veja a documentação: | | `` / `` | Search the current view by text | | ## Painel principal (mesclagem) | Key | Action | Info | |-----|--------|-------------| | `` `` | Escolha o local | | | `` b `` | Pegar todos os pedaços | | | `` `` | Trecho anterior | | | `` `` | Próximo trecho | | | `` `` | Conflito anterior | | | `` `` | Próximo conflito | | | `` z `` | Desfazer | Desfazer resolução de conflitos de última mesclagem. | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | | `` M `` | Abrir ferramenta de merge externa | Execute `git mergetool`. | | `` `` | Retornar ao painel de arquivos | | ## Painel principal (patch build) | Key | Action | Info | |-----|--------|-------------| | `` `` | Ir para o local anterior | | | `` `` | Ir para o próximo trecho | | | `` v `` | Toggle range select | | | `` a `` | Selecione o local | Ativa/desativa modo seleção de hunk | | `` `` | Copy selected text to clipboard | | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` `` | Alternar linhas no caminho | | | `` `` | Sair do construtor de patch personalizado | | | `` / `` | Search the current view by text | | ## Reflog | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy commit hash to clipboard | | | `` `` | Verificar | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Reset copied (cherry-picked) commits selection | | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remotes | Key | Action | Info | |-----|--------|-------------| | `` `` | Ver branches | | | `` n `` | Novo controle | | | `` d `` | Remover | Remover o controle remoto. Quaisquer ramificações locais de rastreamento de um ramo remoto do controle não serão afetadas. | | `` e `` | Editar | Edit the selected remote's name or URL. | | `` f `` | Buscar | Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches. | | `` / `` | Filter the current view by text | | ## Secundário | Key | Action | Info | |-----|--------|-------------| | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | ## Stash | Key | Action | Info | |-----|--------|-------------| | `` `` | Aplicar | Aplique o stash no seu diretório de trabalho. | | `` g `` | Pop | Aplique a entrada de stash no seu diretório de trabalho e remova a entrada de stash. | | `` d `` | Descartar | Remova a entrada do stash da lista de armazenamento. | | `` n `` | Nova branch | Criar um novo ramo a partir da entrada de lixo selecionada. Isso funciona verificando o commit do qual a entrada de lixo foi criada, criar um novo branch a partir desse commit e, em seguida, aplicar a entrada de lixo ao novo branch como um commit adicional. | | `` r `` | Renomear o stasj | | | `` 0 `` | Focus main view | | | `` `` | Ver arquivos | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Status | Key | Action | Info | |-----|--------|-------------| | `` o `` | Abrir o ficheiro de config | Abrir arquivo no aplicativo padrão. | | `` e `` | Editar arquivo de configuração | Abrir arquivo no editor externo. | | `` u `` | Verificar atualização | | | `` `` | Mudar para um repositório recente | | | `` a `` | Mostrar/ciclo todos os logs de filiais | | ## Sub-commits | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy commit hash to clipboard | | | `` `` | Verificar | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Reset copied (cherry-picked) commits selection | | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Ver arquivos | | | `` w `` | View worktree options | | | `` / `` | Search the current view by text | | ## Submodules | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy submodule name to clipboard | | | `` `` | Enter | Enter submodule. After entering the submodule, you can press `` to escape back to the parent repo. | | `` d `` | Remover | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Update selected submodule. | | `` n `` | New submodule | | | `` e `` | Update submodule URL | | | `` i `` | Initialize | Initialize the selected submodule to prepare for fetching. You probably want to follow this up by invoking the 'update' action to fetch the submodule. | | `` b `` | View bulk submodule options | | | `` / `` | Filter the current view by text | | ## Sumário do commit | Key | Action | Info | |-----|--------|-------------| | `` `` | Confirmar | | | `` `` | Fechar | | ## Worktrees | Key | Action | Info | |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | | `` o `` | Abrir no editor | | | `` d `` | Remover | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | Filter the current view by text | | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_ru.md000066400000000000000000000766341500612110400225040ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit Связки клавиш _Связки клавиш_ ## Глобальные сочетания клавиш | Key | Action | Info | |-----|--------|-------------| | `` `` | Переключиться на последний репозиторий | | | `` (fn+up/shift+k) `` | Прокрутить вверх главную панель | | | `` (fn+down/shift+j) `` | Прокрутить вниз главную панель | | | `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | Увеличить размер контекста, отображаемого вокруг изменений в просмотрщике сравнении | Increase the amount of the context shown around changes in the diff view. | | `` { `` | Уменьшите размер контекста, отображаемого вокруг изменений в просмотрщике сравнении | Decrease the amount of the context shown around changes in the diff view. | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | | `` `` | Просмотреть пользовательские параметры патча | | | `` m `` | Просмотреть параметры слияния/перебазирования | View options to abort/continue/skip the current merge/rebase. | | `` R `` | Обновить | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | | | `` _ `` | Предыдущий режим экрана | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q `` | Выйти | | | `` `` | Отменить | | | `` `` | Переключить отображение изменении пробелов в просмотрщике сравнении | Toggle whether or not whitespace changes are shown in the diff view. | | `` z `` | Отменить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git запустить, чтобы отменить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | | `` `` | Повторить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git нужно запустить, чтобы повторить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | ## Навигация по панели списка | Key | Action | Info | |-----|--------|-------------| | `` , `` | Предыдущая страница | | | `` . `` | Следующая страница | | | `` < () `` | Пролистать наверх | | | `` > () `` | Прокрутить вниз | | | `` v `` | Переключить выборку перетаскивания | | | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | Найти | | | `` H `` | Прокрутить влево | | | `` L `` | Прокрутить вправо | | | `` ] `` | Следующая вкладка | | | `` [ `` | Предыдущая вкладка | | ## Worktrees | Key | Action | Info | |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | | `` o `` | Open in editor | | | `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | Filter the current view by text | | ## Вторичный | Key | Action | Info | |-----|--------|-------------| | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Найти | | ## Главная панель (Индексирование) | Key | Action | Info | |-----|--------|-------------| | `` `` | Выбрать предыдущую часть | | | `` `` | Выбрать следующую часть | | | `` v `` | Переключить выборку перетаскивания | | | `` a `` | Переключить выборку частей | Toggle hunk selection mode. | | `` `` | Скопировать выделенный текст в буфер обмена | | | `` `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные | | `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | Открыть файл | Open file in default application. | | `` e `` | Редактировать файл | Open file in external editor. | | `` `` | Вернуться к панели файлов | | | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` E `` | Изменить эту часть | Edit selected hunk in external editor. | | `` c `` | Сохранить изменения | Commit staged changes. | | `` w `` | Закоммитить изменения без предварительного хука коммита | | | `` C `` | Сохранить изменения с помощью редактора git | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | Найти | | ## Главная панель (Обычный) | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | Прокрутить вниз | | | `` mouse wheel up (fn+down) `` | Прокрутить вверх | | | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Найти | | ## Главная панель (Слияние) | Key | Action | Info | |-----|--------|-------------| | `` `` | Выбрать эту часть | | | `` b `` | Выбрать все части | | | `` `` | Выбрать предыдущую часть | | | `` `` | Выбрать следующую часть | | | `` `` | Выбрать предыдущий конфликт | | | `` `` | Выбрать следующий конфликт | | | `` z `` | Отменить | Undo last merge conflict resolution. | | `` e `` | Редактировать файл | Open file in external editor. | | `` o `` | Открыть файл | Open file in default application. | | `` M `` | Открыть внешний инструмент слияния (git mergetool) | Run `git mergetool`. | | `` `` | Вернуться к панели файлов | | ## Главная панель (сборка патчей) | Key | Action | Info | |-----|--------|-------------| | `` `` | Выбрать предыдущую часть | | | `` `` | Выбрать следующую часть | | | `` v `` | Переключить выборку перетаскивания | | | `` a `` | Переключить выборку частей | Toggle hunk selection mode. | | `` `` | Скопировать выделенный текст в буфер обмена | | | `` o `` | Открыть файл | Open file in default application. | | `` e `` | Редактировать файл | Open file in external editor. | | `` `` | Добавить/удалить строку(и) для патча | | | `` `` | Выйти из сборщика пользовательских патчей | | | `` / `` | Найти | | ## Журнал ссылок (Reflog) | Key | Action | Info | |-----|--------|-------------| | `` `` | Скопировать hash коммита в буфер обмена | | | `` `` | Переключить | Checkout the selected commit as a detached HEAD. | | `` y `` | Скопировать атрибут коммита | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Сбросить отобранную (скопированную | cherry-picked) выборку коммитов | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Коммиты | Key | Action | Info | |-----|--------|-------------| | `` `` | Скопировать hash коммита в буфер обмена | | | `` `` | Сбросить отобранную (скопированную | cherry-picked) выборку коммитов | | | `` b `` | Просмотреть параметры бинарного поиска | | | `` s `` | Объединить коммиты (Squash) | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. | | `` f `` | Объединить несколько коммитов в один отбросив сообщение коммита (Fixup) | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. | | `` r `` | Перефразировать коммит | Reword the selected commit's message. | | `` R `` | Переписать коммит с помощью редактора | | | `` d `` | Удалить коммит | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. | | `` e `` | Edit (start interactive rebase) | Изменить коммит | | `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit. If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | Pick | Выбрать коммит (в середине перебазирования) | | `` F `` | Создать fixup коммит | Создать fixup коммит для этого коммита | | `` S `` | Apply fixup commits | Объединить все 'fixup!' коммиты выше в выбранный коммит (автосохранение) | | `` `` | Переместить коммит вниз на один | | | `` `` | Переместить коммит вверх на один | | | `` V `` | Вставить отобранные коммиты (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Править последний коммит с проиндексированными изменениями | | `` a `` | Установить/убрать автора коммита | Set/Reset commit author or set co-author. | | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Пометить коммит тегом | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | Открыть меню журнала | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` `` | Переключить | Checkout the selected commit as a detached HEAD. | | `` y `` | Скопировать атрибут коммита | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | | `` w `` | View worktree options | | | `` / `` | Найти | | ## Локальные Ветки | Key | Action | Info | |-----|--------|-------------| | `` `` | Скопировать название ветки в буфер обмена | | | `` i `` | Показать параметры git-flow | | | `` `` | Переключить | Checkout selected item. | | `` n `` | Новая ветка | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | Создать запрос на принятие изменений | | | `` O `` | Создать параметры запроса принятие изменений | | | `` `` | Скопировать URL запроса на принятие изменений в буфер обмена | | | `` c `` | Переключить по названию | Checkout by name. In the input box you can enter '-' to switch to the last branch. | | `` F `` | Принудительное переключение | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Delete | View delete options for local/remote branch. | | `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. | | `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. | | `` T `` | Создать тег | | | `` s `` | Порядок сортировки | | | `` g `` | Просмотреть параметры сброса | | | `` R `` | Переименовать ветку | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Меню | Key | Action | Info | |-----|--------|-------------| | `` `` | Выполнить | | | `` `` | Закрыть | | | `` / `` | Filter the current view by text | | ## Панель Подтверждения | Key | Action | Info | |-----|--------|-------------| | `` `` | Подтвердить | | | `` `` | Закрыть/отменить | | ## Подкоммиты | Key | Action | Info | |-----|--------|-------------| | `` `` | Скопировать hash коммита в буфер обмена | | | `` `` | Переключить | Checkout the selected commit as a detached HEAD. | | `` y `` | Скопировать атрибут коммита | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Сбросить отобранную (скопированную | cherry-picked) выборку коммитов | | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | | `` w `` | View worktree options | | | `` / `` | Найти | | ## Подмодули | Key | Action | Info | |-----|--------|-------------| | `` `` | Скопировать название подмодуля в буфер обмена | | | `` `` | Enter | Ввести подмодуль | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Обновить подмодуль | | `` n `` | Добавить новый подмодуль | | | `` e `` | Обновить URL подмодуля | | | `` i `` | Initialize | Инициализировать подмодуль | | `` b `` | Просмотреть параметры массового подмодуля | | | `` / `` | Filter the current view by text | | ## Сводка коммита | Key | Action | Info | |-----|--------|-------------| | `` `` | Подтвердить | | | `` `` | Закрыть | | ## Сохранить Изменения Файлов | Key | Action | Info | |-----|--------|-------------| | `` `` | Скопировать название файла в буфер обмена | | | `` y `` | Copy to clipboard | | | `` c `` | Переключить | Переключить файл | | `` d `` | Remove | Отменить изменения коммита в этом файле | | `` o `` | Открыть файл | Open file in default application. | | `` e `` | Edit | Open file in external editor. | | `` `` | Open external diff tool (git difftool) | | | `` `` | Переключить файлы включённые в патч | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Переключить все файлы, включённые в патч | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Введите файл, чтобы добавить выбранные строки в патч (или свернуть каталог переключения) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | | `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | Найти | | ## Статус | Key | Action | Info | |-----|--------|-------------| | `` o `` | Открыть файл конфигурации | Open file in default application. | | `` e `` | Редактировать файл конфигурации | Open file in external editor. | | `` u `` | Проверить обновления | | | `` `` | Переключиться на последний репозиторий | | | `` a `` | Show/cycle all branch logs | | ## Теги | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | Переключить | Checkout the selected tag as a detached HEAD. | | `` n `` | Создать тег | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | Delete | View delete options for local/remote tag. | | `` P `` | Отправить тег | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Удалённые ветки | Key | Action | Info | |-----|--------|-------------| | `` `` | Скопировать название ветки в буфер обмена | | | `` `` | Переключить | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | Новая ветка | | | `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | | `` u `` | Set as upstream | Установить как upstream-ветку переключённую ветку | | `` s `` | Порядок сортировки | | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Удалённые репозитории | Key | Action | Info | |-----|--------|-------------| | `` `` | View branches | | | `` n `` | Добавить новую удалённую ветку | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Редактировать удалённый репозитории | | `` f `` | Получить изменения | Получение изменения из удалённого репозитория | | `` / `` | Filter the current view by text | | ## Файлы | Key | Action | Info | |-----|--------|-------------| | `` `` | Скопировать название файла в буфер обмена | | | `` `` | Переключить индекс | Toggle staged for selected file. | | `` `` | Фильтровать файлы (проиндексированные/непроиндексированные) | | | `` y `` | Copy to clipboard | | | `` c `` | Сохранить изменения | Commit staged changes. | | `` w `` | Закоммитить изменения без предварительного хука коммита | | | `` A `` | Правка последнего коммита | | | `` C `` | Сохранить изменения с помощью редактора git | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | Edit | Open file in external editor. | | `` o `` | Открыть файл | Open file in default application. | | `` i `` | Игнорировать или исключить файл | | | `` r `` | Обновить файлы | | | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | Просмотреть параметры хранилища | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | Все проиндексированные/непроиндексированные | Toggle staged/unstaged for all files in working tree. | | `` `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | | `` d `` | Просмотреть параметры «отмены изменении» | View options for discarding changes to the selected file. | | `` g `` | Просмотреть параметры сброса upstream-ветки | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` `` | Open external diff tool (git difftool) | | | `` M `` | Открыть внешний инструмент слияния (git mergetool) | Run `git mergetool`. | | `` f `` | Получить изменения | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | Найти | | ## Хранилище | Key | Action | Info | |-----|--------|-------------| | `` `` | Применить припрятанные изменения | Apply the stash entry to your working directory. | | `` g `` | Применить припрятанные изменения и тут же удалить их из хранилища | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Удалить припрятанные изменения из хранилища | Remove the stash entry from the stash list. | | `` n `` | Новая ветка | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | | `` r `` | Переименовать хранилище | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | | `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_zh-CN.md000066400000000000000000000551431500612110400227650ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit 按键绑定 _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ ## 全局键绑定 | Key | Action | Info | |-----|--------|-------------| | `` `` | 切换到最近的仓库 | | | `` (fn+up/shift+k) `` | 向上滚动主面板 | | | `` (fn+down/shift+j) `` | 向下滚动主面板 | | | `` @ `` | 打开命令日志菜单 | 查看命令日志的选项,例如显示/隐藏命令日志以及聚焦命令日志 | | `` P `` | 推送 | 推送当前分支到它的上游。如果上游未配置,你可以在弹窗中配置上游分支。 | | `` p `` | 拉取 | 从当前分支的远程分支获取改动。如果上游未配置,你可以在弹窗中配置上游分支。 | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | 扩大差异视图中显示的上下文范围 | 增加diff视图中围绕更改显示的上下文数量 | | `` { `` | 缩小差异视图中显示的上下文范围 | 减少diff视图中围绕更改显示的上下文数量 | | `` : `` | 执行 Shell 命令 | Bring up a prompt where you can enter a shell command to execute. | | `` `` | 查看自定义补丁选项 | | | `` m `` | 查看 合并/变基 选项 | 查看当前合并或变基的中止、继续、跳过选项 | | `` R `` | 刷新 | 刷新git状态(即在后台上运行`git status`,`git branch`等命令以更新面板内容) 不会运行`git fetch` | | `` + `` | 下一屏模式(正常/半屏/全屏) | | | `` _ `` | 上一屏模式 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | | `` W `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | | `` `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | | `` q `` | 退出 | | | `` `` | 取消 | | | `` `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在diff视图中显示空白更改 | | `` z `` | 撤销 | Reflog将用于确定运行哪个git命令来撤消最后一个git命令。这并不包括对工作树的更改,只考虑提交。 | | `` `` | 重做 | Reflog将用于确定运行哪个git命令来重做上一个git命令。这并不包括对工作树的更改,只考虑提交。 | ## 列表面板导航 | Key | Action | Info | |-----|--------|-------------| | `` , `` | 上一页 | | | `` . `` | 下一页 | | | `` < () `` | 滚动到顶部 | | | `` > () `` | 滚动到底部 | | | `` v `` | 切换拖动选择 | | | `` `` | 向下扩展选择范围 | | | `` `` | 向上扩展选择范围 | | | `` / `` | 开始搜索 | | | `` H `` | 向左滚动 | | | `` L `` | 向右滚动 | | | `` ] `` | 下一个标签 | | | `` [ `` | 上一个标签 | | ## Reflog | Key | Action | Info | |-----|--------|-------------| | `` `` | 将提交的 hash 复制到剪贴板 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(例如,hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,你可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 重置已拣选(复制)的提交 | | | `` `` | 使用外部差异比较工具(git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 查看提交 | | | `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 子提交 | Key | Action | Info | |-----|--------|-------------| | `` `` | 将提交的 hash 复制到剪贴板 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(例如,hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,你可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 重置已拣选(复制)的提交 | | | `` `` | 使用外部差异比较工具(git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 查看提交的文件 | | | `` w `` | 查看工作区选项 | | | `` / `` | 开始搜索 | | ## 子模块 | Key | Action | Info | |-----|--------|-------------| | `` `` | 将子模块名称复制到剪贴板 | | | `` `` | 进入 | 输入子模块 | | `` d `` | 删除 | 删除选定的子模块及其相应的目录 | | `` u `` | 更新 | 更新子模块 | | `` n `` | 添加新的子模块 | | | `` e `` | 更新子模块 URL | | | `` i `` | 初始化 | 初始化子模块 | | `` b `` | 查看批量子模块选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 工作区 | Key | Action | Info | |-----|--------|-------------| | `` n `` | 新建工作树 | | | `` `` | 切换 | 切换到选中的工作树 | | `` o `` | 在编辑器中编写 | | | `` d `` | 删除 | 删除选定的工作树。这将删除工作树的目录以及 .git 目录中有关工作树的元数据。 | | `` / `` | 通过文本过滤当前视图 | | ## 提交 | Key | Action | Info | |-----|--------|-------------| | `` `` | 将提交的 hash 复制到剪贴板 | | | `` `` | 重置已拣选(复制)的提交 | | | `` b `` | 查看二分查找选项 | | | `` s `` | 压缩(Squash) | 将已选提交压缩到该提交之下。这些选定的提交的消息会附加到该提交的消息之下。 | | `` f `` | 修正 (fixup) | 将选定的提交合并到其下面的提交中。与压缩类似,但所选提交的消息将被丢弃。 | | `` r `` | 改写提交 | 重写所选提交的消息。 | | `` R `` | 使用编辑器重命名提交 | | | `` d `` | 删除提交 | 删除选中的提交。这将通过变基从分支中删除该提交,如果该提交修改的内容依赖于后续的提交,则需要解决合并冲突。 | | `` e `` | 编辑(开始交互式变基) | 编辑提交 | | `` i `` | 开始交互式变基 | 为分支上的提交启动交互式变基。这将包括从 HEAD 提交到第一个合并提交或主分支提交的所有提交。 如果您想从所选提交启动交互式变基,请按 `e`。 | | `` p `` | 拣选(Pick) | 标记选中的提交为 picked(变基过程中)。这意味该提交将在后续的变基中保留。 | | `` F `` | 为此提交创建修正 | 创建修正提交 | | `` S `` | 应用该修复提交 | 压缩所选提交之上或当前分支的所有 “fixup!” 提交(自动压缩)。 | | `` `` | 下移提交 | | | `` `` | 上移提交 | | | `` V `` | 粘贴提交(拣选) | | | `` B `` | 标记一个主提交用于变基 | 选择下一次变基的主提交。当您变基到一个分支时,只有高于主提交的提交才会被引入。这使用“git rebase --onto”命令。 | | `` A `` | 修补(Amend) | 用已暂存的变更来修补提交 | | `` a `` | 修补提交属性 | 设置或重置提交的作者,或添加其他作者。 | | `` t `` | 撤销(Revert) | 为所选提交创建还原提交,这会反向应用所选提交的更改。 | | `` T `` | 标签提交 | 创建一个新标签指向所选提交。你可以在弹窗中输入标签名称和描述(可选)。 | | `` `` | 打开日志菜单 | 查看提交日志的选项,例如更改排序顺序、隐藏 git graph、显示整个 git graph。 | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(例如,hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,你可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 查看提交的文件 | | | `` w `` | 查看工作区选项 | | | `` / `` | 开始搜索 | | ## 提交信息 | Key | Action | Info | |-----|--------|-------------| | `` `` | 确认 | | | `` `` | 关闭 | | ## 提交文件 | Key | Action | Info | |-----|--------|-------------| | `` `` | 将文件名复制到剪贴板 | | | `` y `` | 复制到剪贴板 | | | `` c `` | 检出 | 检出文件 | | `` d `` | 删除 | 放弃对此文件的提交变更 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` e `` | 编辑 | 使用外部编辑器打开文件 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` `` | 补丁中包含的切换文件 | 切换文件是否包含在自定义补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | | `` a `` | 操作所有文件 | 添加或删除所有提交中的文件到自定义的补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | | `` `` | 输入文件以将所选行添加到补丁中(或切换目录折叠) | 如果已选择一个文件,则Enter进入该文件,以便您可以向自定义补丁添加/删除单独的行。如果选择了目录,则切换目录。 | | `` ` `` | 切换文件树视图 | 在平铺部署与树布局之间切换文件视图。平铺布局在一个列表中展示所有文件路径,树布局则根据目录分组展示。 | | `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 | | `` = `` | 展开全部文件 | 展开文件树中的全部目录 | | `` 0 `` | Focus main view | | | `` / `` | 开始搜索 | | ## 文件 | Key | Action | Info | |-----|--------|-------------| | `` `` | 将文件名复制到剪贴板 | | | `` `` | 切换暂存状态 | 为选定的文件切换暂存状态 | | `` `` | 通过状态过滤文件 | | | `` y `` | 复制到剪贴板 | | | `` c `` | 提交变更 | 提交暂存文件 | | `` w `` | 提交变更而无需预先提交钩子 | | | `` A `` | 修补最后一次提交 | | | `` C `` | 使用 Git 编辑器提交变更 | | | `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | | `` e `` | 编辑 | 使用外部编辑器打开文件 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` i `` | 忽略文件 | | | `` r `` | 刷新文件 | | | `` s `` | 贮藏 | 贮藏所有变更.若要使用其他贮藏变体,请使用查看贮藏选项快捷键 | | `` S `` | 查看贮藏选项 | 查看贮藏选项(例如:贮藏所有、贮藏已暂存变更、贮藏未暂存变更) | | `` a `` | 切换所有文件的暂存状态 | 切换工作区中所有文件的已暂存/未暂存状态 | | `` `` | 暂存单个 块/行 用于文件, 或 折叠/展开 目录 | 如果选中的是一个文件,则会进入到暂存视图,以便可以暂存单个代码块/行。如果选中的是一个目录,则会折叠/展开这个目录 | | `` d `` | 查看'放弃变更'选项 | 查看选中文件的放弃变更选项 | | `` g `` | 查看上游重置选项 | | | `` D `` | 重置 | 查看工作树的重置选项(例如:清除工作树)。 | | `` ` `` | 切换文件树视图 | 在平铺部署与树布局之间切换文件视图。平铺布局在一个列表中展示所有文件路径,树布局则根据目录分组展示。 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` M `` | 打开外部合并工具(git mergetool) | 执行 `git mergetool`. | | `` f `` | 抓取 | 从远程获取变更 | | `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 | | `` = `` | 展开全部文件 | 展开文件树中的全部目录 | | `` 0 `` | Focus main view | | | `` / `` | 开始搜索 | | ## 本地分支 | Key | Action | Info | |-----|--------|-------------| | `` `` | 将分支名称复制到剪贴板 | | | `` i `` | 显示 git-flow 选项 | | | `` `` | 检出 | 检出选中的项目 | | `` n `` | 新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | 创建拉取请求 | | | `` O `` | 创建拉取请求选项 | | | `` `` | 将拉取请求 URL 复制到剪贴板 | | | `` c `` | 按名称检出 | 按名称检出。在输入框中,您可以输入'-' 来切换到最后一个分支。 | | `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 | | `` d `` | 删除 | 查看本地/远程分支的删除选项 | | `` r `` | 将已检出的分支变基到该分支 | 将检出的分支变基到所选的分支上。 | | `` M `` | 合并到当前检出的分支 | Merge selected branch into currently checked out branch. | | `` f `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 | | `` T `` | 创建标签 | | | `` s `` | 排序 | | | `` g `` | 查看重置选项 | | | `` R `` | 重命名分支 | | | `` u `` | 查看上游选项 | 查看与分支上游相关的选项,例如设置/取消设置上游和重置为上游。 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 查看提交 | | | `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 构建补丁中 | Key | Action | Info | |-----|--------|-------------| | `` `` | 选择上一个区块 | | | `` `` | 选择下一个区块 | | | `` v `` | 切换拖动选择 | | | `` a `` | 切换选择代码块 | 切换代码块选择模式 | | `` `` | 将选中文本复制到剪贴板 | | | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` `` | 添加/移除 行到补丁 | | | `` `` | 退出逐行模式 | | | `` / `` | 开始搜索 | | ## 标签 | Key | Action | Info | |-----|--------|-------------| | `` `` | 将标签复制到剪贴板 | | | `` `` | 检出 | 检出选择的标签作为分离的HEAD | | `` n `` | 创建标签 | 基于当前提交创建一个新标签。你将在弹窗中输入标签名称和描述(可选)。 | | `` d `` | 删除 | 查看本地/远程标签的删除选项 | | `` P `` | 推送标签 | 推送选择的标签到远端。你将在弹窗中选择一个远端。 | | `` g `` | 重置 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 查看提交 | | | `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 次要 | Key | Action | Info | |-----|--------|-------------| | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | Exit back to side panel | | | `` / `` | 开始搜索 | | ## 正在合并 | Key | Action | Info | |-----|--------|-------------| | `` `` | 选中区块 | | | `` b `` | 选中所有区块 | | | `` `` | 选择顶部块 | | | `` `` | 选择底部块 | | | `` `` | 选择上一个冲突 | | | `` `` | 选择下一个冲突 | | | `` z `` | 撤销 | 撤消上次合并冲突解决 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` M `` | 打开外部合并工具(git mergetool) | 执行 `git mergetool`. | | `` `` | 返回文件面板 | | ## 正在暂存 | Key | Action | Info | |-----|--------|-------------| | `` `` | 选择上一个区块 | | | `` `` | 选择下一个区块 | | | `` v `` | 切换拖动选择 | | | `` a `` | 切换选择代码块 | 切换代码块选择模式 | | `` `` | 将选中文本复制到剪贴板 | | | `` `` | 切换暂存状态 | 切换行暂存状态 | | `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` `` | 返回文件面板 | | | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 | | `` c `` | 提交变更 | 提交暂存文件 | | `` w `` | 提交变更而无需预先提交钩子 | | | `` C `` | 使用 Git 编辑器提交变更 | | | `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | | `` / `` | 开始搜索 | | ## 正常 | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | 向下滚动 | | | `` mouse wheel up (fn+down) `` | 向上滚动 | | | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | Exit back to side panel | | | `` / `` | 开始搜索 | | ## 状态 | Key | Action | Info | |-----|--------|-------------| | `` o `` | 打开配置文件 | 使用默认程序打开该文件 | | `` e `` | 编辑配置文件 | 使用外部编辑器打开文件 | | `` u `` | 检查更新 | | | `` `` | 切换到最近的仓库 | | | `` a `` | Show/cycle all branch logs | | ## 确认面板 | Key | Action | Info | |-----|--------|-------------| | `` `` | 确认 | | | `` `` | 关闭 | | ## 菜单 | Key | Action | Info | |-----|--------|-------------| | `` `` | 执行 | | | `` `` | 关闭 | | | `` / `` | 通过文本过滤当前视图 | | ## 贮藏 | Key | Action | Info | |-----|--------|-------------| | `` `` | 应用 | 将贮藏项应用到您的工作目录。 | | `` g `` | 应用并删除 | 将存储项应用到工作目录并删除存储项。 | | `` d `` | 删除 | 从贮藏列表中删除该贮藏项 | | `` n `` | 新分支 | 从选定的贮藏项创建一个新分支。这是通过 git 检查创建贮藏项的提交,从该提交创建一个新分支,然后将贮藏项作为附加提交应用到新分支来实现的。 | | `` r `` | 重命名贮藏 | | | `` 0 `` | Focus main view | | | `` `` | 查看提交的文件 | | | `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 远程 | Key | Action | Info | |-----|--------|-------------| | `` `` | 查看分支 | | | `` n `` | 添加新的远程仓库 | | | `` d `` | 删除 | 删除选中的远程。从远程跟踪远程分支的任何本地分支都不会受到影响。 | | `` e `` | 编辑 | 编辑远程仓库 | | `` f `` | 抓取 | 抓取远程仓库 | | `` / `` | 通过文本过滤当前视图 | | ## 远程分支 | Key | Action | Info | |-----|--------|-------------| | `` `` | 将分支名称复制到剪贴板 | | | `` `` | 检出 | 基于当前选中的远程分支检出一个新的本地分支,或者将远程分支作分离的HEAD。 | | `` n `` | 新分支 | | | `` M `` | 合并到当前检出的分支 | Merge selected branch into currently checked out branch. | | `` r `` | 将已检出的分支变基到该分支 | 将检出的分支变基到所选的分支上。 | | `` d `` | 删除 | 从远程删除远程分支。 | | `` u `` | 设置为上游 | 设置为检出分支的上游 | | `` s `` | 排序 | | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 查看提交 | | | `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | lazygit-0.50.0+ds1/docs/keybindings/Keybindings_zh-TW.md000066400000000000000000000554741500612110400230260ustar00rootroot00000000000000_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go generate ./...` from the project root._ # Lazygit 鍵盤快捷鍵 _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_ ## 全域快捷鍵 | Key | Action | Info | |-----|--------|-------------| | `` `` | 切換到最近使用的版本庫 | | | `` (fn+up/shift+k) `` | 向上捲動主面板 | | | `` (fn+down/shift+j) `` | 向下捲動主面板 | | | `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 | | `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename. | | `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | Increase the amount of the context shown around changes in the diff view. | | `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | Decrease the amount of the context shown around changes in the diff view. | | `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | | `` `` | 檢視自訂補丁選項 | | | `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. | | `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | | | `` _ `` | 上一個螢幕模式 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q `` | 結束 | | | `` `` | 取消 | | | `` `` | 切換是否在差異檢視中顯示空格變更 | Toggle whether or not whitespace changes are shown in the diff view. | | `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 | | `` `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 | ## 移動 | Key | Action | Info | |-----|--------|-------------| | `` , `` | 上一頁 | | | `` . `` | 下一頁 | | | `` < () `` | 捲動到頂部 | | | `` > () `` | 捲動到底部 | | | `` v `` | 切換拖曳選擇 | | | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | 搜尋 | | | `` H `` | 向左捲動 | | | `` L `` | 向右捲動 | | | `` ] `` | 下一個索引標籤 | | | `` [ `` | 上一個索引標籤 | | ## 主面板 (補丁生成) | Key | Action | Info | |-----|--------|-------------| | `` `` | 選擇上一段 | | | `` `` | 選擇下一段 | | | `` v `` | 切換拖曳選擇 | | | `` a `` | 切換選擇程式碼塊 | Toggle hunk selection mode. | | `` `` | 複製所選文本至剪貼簿 | | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` `` | 向 (或從) 補丁中添加/刪除行 | | | `` `` | 退出自訂補丁建立器 | | | `` / `` | 搜尋 | | ## 主面板(一般) | Key | Action | Info | |-----|--------|-------------| | `` mouse wheel down (fn+up) `` | 向下捲動 | | | `` mouse wheel up (fn+down) `` | 向上捲動 | | | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 搜尋 | | ## 主面板(合併) | Key | Action | Info | |-----|--------|-------------| | `` `` | 挑選程式碼片段 | | | `` b `` | 挑選所有程式碼片段 | | | `` `` | 選擇上一段 | | | `` `` | 選擇下一段 | | | `` `` | 選擇上一個衝突 | | | `` `` | 選擇下一個衝突 | | | `` z `` | 復原 | Undo last merge conflict resolution. | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` M `` | 開啟外部合併工具 | 執行 `git mergetool`。 | | `` `` | 返回檔案面板 | | ## 主面板(預存) | Key | Action | Info | |-----|--------|-------------| | `` `` | 選擇上一段 | | | `` `` | 選擇下一段 | | | `` v `` | 切換拖曳選擇 | | | `` a `` | 切換選擇程式碼塊 | Toggle hunk selection mode. | | `` `` | 複製所選文本至剪貼簿 | | | `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | | `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` `` | 返回檔案面板 | | | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. | | `` c `` | 提交變更 | 提交暫存區變更 | | `` w `` | 沒有預提交 hook 就提交更改 | | | `` C `` | 使用 git 編輯器提交變更 | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | 搜尋 | | ## 功能表 | Key | Action | Info | |-----|--------|-------------| | `` `` | 執行 | | | `` `` | 關閉 | | | `` / `` | 搜尋 | | ## 子提交 | Key | Action | Info | |-----|--------|-------------| | `` `` | 複製提交 hash 到剪貼簿 | | | `` `` | 檢出 | Checkout the selected commit as a detached HEAD. | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 重設選定的揀選 (複製) 提交 | | | `` `` | 開啟外部差異工具 (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | | `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 子模組 | Key | Action | Info | |-----|--------|-------------| | `` `` | 複製子模組名稱到剪貼簿 | | | `` `` | Enter | 進入子模組 | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | 更新子模組 | | `` n `` | 新增子模組 | | | `` e `` | 更新子模組 URL | | | `` i `` | Initialize | 初始化子模組 | | `` b `` | 查看批量子模組選項 | | | `` / `` | 搜尋 | | ## 工作目錄 | Key | Action | Info | |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | | `` o `` | 在編輯器中開啟 | | | `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | 搜尋 | | ## 提交 | Key | Action | Info | |-----|--------|-------------| | `` `` | 複製提交 hash 到剪貼簿 | | | `` `` | 重設選定的揀選 (複製) 提交 | | | `` b `` | 查看二分選項 | | | `` s `` | 壓縮 (Squash) | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. | | `` f `` | 修復 (Fixup) | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. | | `` r `` | 改寫提交 | 改寫選中的提交訊息 | | `` R `` | 使用編輯器改寫提交 | | | `` d `` | 刪除提交 | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. | | `` e `` | 編輯(開始互動變基) | 編輯提交 | | `` i `` | 開始互動變基 | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit. If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | 挑選 | 挑選提交 (於變基過程中) | | `` F `` | 建立修復提交 | 為此提交建立修復提交 | | `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? | | `` `` | 向下移動提交 | | | `` `` | 向上移動提交 | | | `` V `` | 貼上提交 (揀選) | | | `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 | | `` A `` | 修改 | 使用已預存的更改修正提交 | | `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. | | `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` `` | 檢出 | Checkout the selected commit as a detached HEAD. | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 開啟外部差異工具 (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | | `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 提交摘要 | Key | Action | Info | |-----|--------|-------------| | `` `` | 確認 | | | `` `` | 關閉 | | ## 提交檔案 | Key | Action | Info | |-----|--------|-------------| | `` `` | 複製檔案名稱到剪貼簿 | | | `` y `` | 複製到剪貼簿 | | | `` c `` | 檢出 | 檢出檔案 | | `` d `` | Remove | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` e `` | 編輯 | 使用外部編輯器開啟 | | `` `` | 開啟外部差異工具 (git difftool) | | | `` `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | | `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | 搜尋 | | ## 收藏 (Stash) | Key | Action | Info | |-----|--------|-------------| | `` `` | 套用 | Apply the stash entry to your working directory. | | `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | 捨棄 | Remove the stash entry from the stash list. | | `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | | `` r `` | 重新命名收藏 | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | | `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 日誌 | Key | Action | Info | |-----|--------|-------------| | `` `` | 複製提交 hash 到剪貼簿 | | | `` `` | 檢出 | Checkout the selected commit as a detached HEAD. | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 重設選定的揀選 (複製) 提交 | | | `` `` | 開啟外部差異工具 (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | | `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 本地分支 | Key | Action | Info | |-----|--------|-------------| | `` `` | 複製分支名稱到剪貼簿 | | | `` i `` | 顯示 git-flow 選項 | | | `` `` | 檢出 | 檢出選定的項目。 | | `` n `` | 新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first. Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | 建立拉取請求 | | | `` O `` | 建立拉取請求選項 | | | `` `` | 複製拉取請求的 URL 到剪貼板 | | | `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the last branch. | | `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | 刪除 | View delete options for local/remote branch. | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 | | `` T `` | 建立標籤 | | | `` s `` | 排序規則 | | | `` g `` | 檢視重設選項 | | | `` R `` | 重新命名分支 | | | `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) | | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | | `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 標籤 | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | 檢出 | Checkout the selected tag as a detached HEAD. | | `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | 刪除 | View delete options for local/remote tag. | | `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | | `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 檔案 | Key | Action | Info | |-----|--------|-------------| | `` `` | 複製檔案名稱到剪貼簿 | | | `` `` | 切換預存 | Toggle staged for selected file. | | `` `` | 篩選檔案 (預存/未預存) | | | `` y `` | 複製到剪貼簿 | | | `` c `` | 提交變更 | 提交暫存區變更 | | `` w `` | 沒有預提交 hook 就提交更改 | | | `` A `` | 修改上次提交 | | | `` C `` | 使用 git 編輯器提交變更 | | | `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | 編輯 | 使用外部編輯器開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` i `` | 忽略或排除檔案 | | | `` r `` | 重新整理檔案 | | | `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. | | `` `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | | `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 | | `` g `` | 檢視遠端重設選項 | | | `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory. | | `` `` | 開啟外部差異工具 (git difftool) | | | `` M `` | 開啟外部合併工具 | 執行 `git mergetool`。 | | `` f `` | 擷取 | 同步遠端異動 | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | | `` 0 `` | Focus main view | | | `` / `` | 搜尋 | | ## 次要 | Key | Action | Info | |-----|--------|-------------| | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 搜尋 | | ## 狀態 | Key | Action | Info | |-----|--------|-------------| | `` o `` | 開啟設定檔案 | 使用預設軟體開啟 | | `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` u `` | 檢查更新 | | | `` `` | 切換到最近使用的版本庫 | | | `` a `` | Show/cycle all branch logs | | ## 確認面板 | Key | Action | Info | |-----|--------|-------------| | `` `` | 確認 | | | `` `` | 關閉/取消 | | ## 遠端 | Key | Action | Info | |-----|--------|-------------| | `` `` | View branches | | | `` n `` | 新增遠端 | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | 編輯 | 編輯遠端 | | `` f `` | 擷取 | 擷取遠端 | | `` / `` | 搜尋 | | ## 遠端分支 | Key | Action | Info | |-----|--------|-------------| | `` `` | 複製分支名稱到剪貼簿 | | | `` `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 新分支 | | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. | | `` d `` | 刪除 | Delete the remote branch from the remote. | | `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 | | `` s `` | 排序規則 | | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | | `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | lazygit-0.50.0+ds1/go.mod000066400000000000000000000074371500612110400150510ustar00rootroot00000000000000module github.com/jesseduffield/lazygit go 1.24.0 require ( dario.cat/mergo v1.0.1 github.com/adrg/xdg v0.4.0 github.com/atotto/clipboard v0.1.4 github.com/aybabtme/humanlog v0.4.1 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/creack/pty v1.1.11 github.com/gdamore/tcell/v2 v2.8.1 github.com/go-errors/errors v1.5.1 github.com/gookit/color v1.4.2 github.com/integrii/flaggy v1.4.0 github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918 github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd github.com/jesseduffield/gocui v0.3.1-0.20250421160159-82c9aaeba2b9 github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3 github.com/kyokomi/emoji/v2 v2.2.8 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.16 github.com/mgutz/str v1.2.0 github.com/mitchellh/go-ps v1.0.0 github.com/sahilm/fuzzy v0.1.0 github.com/samber/lo v1.31.0 github.com/sanity-io/litter v1.5.2 github.com/sasha-s/go-deadlock v0.3.5 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.9.5 github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad github.com/stefanhaller/git-todo-parser v0.0.7-0.20250429125209-dcf39e4641f5 github.com/stretchr/testify v1.10.0 github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 golang.org/x/sync v0.13.0 gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cloudflare/circl v1.6.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.9.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-logfmt/logfmt v0.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/hpcloud/tail v1.0.0 // indirect github.com/invopop/jsonschema v0.10.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.11 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/onsi/ginkgo v1.10.3 // indirect github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) lazygit-0.50.0+ds1/go.sum000066400000000000000000002051141500612110400150660ustar00rootroot00000000000000cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aybabtme/humanlog v0.4.1 h1:D8d9um55rrthJsP8IGSHBcti9lTb/XknmDAX6Zy8tek= github.com/aybabtme/humanlog v0.4.1/go.mod h1:B0bnQX4FTSU3oftPMTTPvENCy8LqixLDvYJA9TUCAGo= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 h1:tuijfIjZyjZaHq9xDUh0tNitwXshJpbLkqMOJv4H3do= github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.8.0/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/integrii/flaggy v1.4.0 h1:A1x7SYx4jqu5NSrY14z8Z+0UyX2S5ygfJJrfolWR3zM= github.com/integrii/flaggy v1.4.0/go.mod h1:tnTxHeTJbah0gQ6/K0RW0J7fMUBk9MCF5blhm43LNpI= github.com/invopop/jsonschema v0.10.0 h1:c1ktzNLBun3LyQQhyty5WE3lulbOdIIyOVlkmDLehcE= github.com/invopop/jsonschema v0.10.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918 h1:meoUDZGF6jZAbhW5IBwj92mTqGmrOn+Cuu0jM7/aUcs= github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk= github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd h1:ViKj6qth8FgcIWizn9KiACWwPemWSymx62OPN0tHT+Q= github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd/go.mod h1:lRhCiBr6XjQrvcQVa+UYsy/99d3wMXn/a0nSQlhnhlA= github.com/jesseduffield/gocui v0.3.1-0.20250421160159-82c9aaeba2b9 h1:k23sCKHCNpAvwJP8Yr16CBUItuarmUHBGH7FaAm2glc= github.com/jesseduffield/gocui v0.3.1-0.20250421160159-82c9aaeba2b9/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s= github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a h1:UDeJ3EBk04bXDLOPvuqM3on8HvyJfISw0+UMqW+0a4g= github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a/go.mod h1:FSWDLKT0NQpntbDd1H3lbz51fhCVlMzy/J0S6nM727Q= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ= github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e h1:uw/oo+kg7t/oeMs6sqlAwr85ND/9cpO3up3VxphxY0U= github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e/go.mod h1:u60qdFGXRd36jyEXxetz0vQceQIxzI13lIo3EFUDf4I= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3 h1:s995u+gNQADMaixtNOs+jilRC/Q78q0UXSI7+4T0cDE= github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3/go.mod h1:MCbEh21gjOzxc31udr3u4QM9DAdf8TFJCZz3u5hYIxA= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kyokomi/emoji/v2 v2.2.8 h1:jcofPxjHWEkJtkIbcLHvZhxKgCPl6C7MyjTrD4KDqUE= github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgutz/str v1.2.0 h1:4IzWSdIz9qPQWLfKZ0rJcV0jcUDpxvP4JVZ4GXQyvSw= github.com/mgutz/str v1.2.0/go.mod h1:w1v0ofgLaJdoD0HpQ3fycxKD1WtxpjSo151pK/31q6w= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/onsi/ginkgo v1.10.3 h1:OoxbjfXVZyod1fmWYhI7SEyaD8B00ynP3T+D5GiyHOY= github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/samber/lo v1.31.0 h1:Sfa+/064Tdo4SvlohQUQzBhgSer9v/coGvKQI/XLWAM= github.com/samber/lo v1.31.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8= github.com/sanity-io/litter v1.5.2 h1:AnC8s9BMORWH5a4atZ4D6FPVvKGzHcnc5/IVTa87myw= github.com/sanity-io/litter v1.5.2/go.mod h1:5Z71SvaYy5kcGtyglXOC9rrUi3c1E8CamFWjQsazTh0= github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJGQTUpVfEMJJd4nRFXogbc= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stefanhaller/git-todo-parser v0.0.7-0.20250429125209-dcf39e4641f5 h1:ZCI0NPs0xXd00Ej9lX+wwbHjQDkstJa3kUbX7WCOF8I= github.com/stefanhaller/git-todo-parser v0.0.7-0.20250429125209-dcf39e4641f5/go.mod h1:HFt9hGqMzgQ+gVxMKcvTvGaFz4Y0yYycqqAp2V3wcJY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0 h1:KzcWKJ0nMAmGoBhYVMnkWc1rXjB42lKy5aIys4TdLOA= gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0/go.mod h1:XoytMOotjRRJVkIsQdxsPIioRLYFISEaY9a4tftOXAo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= lazygit-0.50.0+ds1/main.go000066400000000000000000000006471500612110400152120ustar00rootroot00000000000000package main import ( "github.com/jesseduffield/lazygit/pkg/app" ) // These values may be set by the build script via the LDFLAGS argument var ( commit string date string version string buildSource = "unknown" ) func main() { ldFlagsBuildInfo := &app.BuildInfo{ Commit: commit, Date: date, Version: version, BuildSource: buildSource, } app.Start(ldFlagsBuildInfo, nil) } lazygit-0.50.0+ds1/pkg/000077500000000000000000000000001500612110400145115ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/app/000077500000000000000000000000001500612110400152715ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/app/app.go000066400000000000000000000157561500612110400164160ustar00rootroot00000000000000package app import ( "bufio" "fmt" "io" "log" "os" "path/filepath" "strings" "github.com/go-errors/errors" "github.com/sirupsen/logrus" "github.com/spf13/afero" appTypes "github.com/jesseduffield/lazygit/pkg/app/types" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/gui" "github.com/jesseduffield/lazygit/pkg/i18n" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" "github.com/jesseduffield/lazygit/pkg/logs" "github.com/jesseduffield/lazygit/pkg/updates" ) // App is the struct that's instantiated from within main.go and it manages // bootstrapping and running the application. type App struct { *common.Common closers []io.Closer Config config.AppConfigurer OSCommand *oscommands.OSCommand Gui *gui.Gui } func Run( config config.AppConfigurer, common *common.Common, startArgs appTypes.StartArgs, ) { app, err := NewApp(config, startArgs.IntegrationTest, common) if err == nil { err = app.Run(startArgs) } if err != nil { if errorMessage, known := knownError(common.Tr, err); known { log.Fatal(errorMessage) } newErr := errors.Wrap(err, 0) stackTrace := newErr.ErrorStack() app.Log.Error(stackTrace) log.Fatalf("%s: %s\n\n%s", common.Tr.ErrorOccurred, constants.Links.Issues, stackTrace) } } func NewCommon(config config.AppConfigurer) (*common.Common, error) { userConfig := config.GetUserConfig() appState := config.GetAppState() log := newLogger(config) // Initialize with English for the time being; the real translation set for // the configured language will be read after reading the user config tr := i18n.EnglishTranslationSet() cmn := &common.Common{ Log: log, Tr: tr, AppState: appState, Debug: config.GetDebug(), Fs: afero.NewOsFs(), } cmn.SetUserConfig(userConfig) return cmn, nil } func newLogger(cfg config.AppConfigurer) *logrus.Entry { if cfg.GetDebug() { logPath, err := config.LogPath() if err != nil { log.Fatal(err) } return logs.NewDevelopmentLogger(logPath) } else { return logs.NewProductionLogger() } } // NewApp bootstrap a new application func NewApp(config config.AppConfigurer, test integrationTypes.IntegrationTest, common *common.Common) (*App, error) { app := &App{ closers: []io.Closer{}, Config: config, Common: common, } app.OSCommand = oscommands.NewOSCommand(common, config, oscommands.GetPlatform(), oscommands.NewNullGuiIO(app.Log)) updater, err := updates.NewUpdater(common, config, app.OSCommand) if err != nil { return app, err } dirName, err := os.Getwd() if err != nil { return app, err } gitVersion, err := app.validateGitVersion() if err != nil { return app, err } // If we're not in a repo, GetRepoPaths will return an error. The error is moot for us // at this stage, since we'll try to init a new repo in setupRepo(), below repoPaths, err := git_commands.GetRepoPaths(app.OSCommand.Cmd, gitVersion) if err != nil { common.Log.Infof("Error getting repo paths: %v", err) } showRecentRepos, err := app.setupRepo(repoPaths) if err != nil { return app, err } // used for testing purposes if os.Getenv("SHOW_RECENT_REPOS") == "true" { showRecentRepos = true } app.Gui, err = gui.NewGui(common, config, gitVersion, updater, showRecentRepos, dirName, test) if err != nil { return app, err } return app, nil } func (app *App) validateGitVersion() (*git_commands.GitVersion, error) { version, err := git_commands.GetGitVersion(app.OSCommand) // if we get an error anywhere here we'll show the same status minVersionError := errors.New(app.Tr.MinGitVersionError) if err != nil { return nil, minVersionError } if version.IsOlderThan(2, 22, 0) { return nil, minVersionError } return version, nil } func isDirectoryAGitRepository(dir string) (bool, error) { info, err := os.Stat(filepath.Join(dir, ".git")) return info != nil, err } func openRecentRepo(app *App) bool { for _, repoDir := range app.Config.GetAppState().RecentRepos { if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo { if err := os.Chdir(repoDir); err == nil { return true } } } return false } func (app *App) setupRepo( repoPaths *git_commands.RepoPaths, ) (bool, error) { if env.GetGitDirEnv() != "" { // we've been given the git dir directly. Skip setup return false, nil } // if we are not in a git repo, we ask if we want to `git init` if repoPaths == nil { cwd, err := os.Getwd() if err != nil { return false, err } if isRepo, err := isDirectoryAGitRepository(cwd); isRepo { return false, err } var shouldInitRepo bool initialBranchArg := "" switch app.UserConfig().NotARepository { case "prompt": // Offer to initialize a new repository in current directory. fmt.Print(app.Tr.CreateRepo) response, _ := bufio.NewReader(os.Stdin).ReadString('\n') shouldInitRepo = (strings.Trim(response, " \r\n") == "y") if shouldInitRepo { // Ask for the initial branch name fmt.Print(app.Tr.InitialBranch) response, _ := bufio.NewReader(os.Stdin).ReadString('\n') if trimmedResponse := strings.Trim(response, " \r\n"); len(trimmedResponse) > 0 { initialBranchArg += "--initial-branch=" + trimmedResponse } } case "create": shouldInitRepo = true case "skip": shouldInitRepo = false case "quit": fmt.Fprintln(os.Stderr, app.Tr.NotARepository) os.Exit(1) default: fmt.Fprintln(os.Stderr, app.Tr.IncorrectNotARepository) os.Exit(1) } if shouldInitRepo { args := []string{"git", "init"} if initialBranchArg != "" { args = append(args, initialBranchArg) } if err := app.OSCommand.Cmd.New(args).Run(); err != nil { return false, err } return false, nil } // check if we have a recent repo we can open for _, repoDir := range app.Config.GetAppState().RecentRepos { if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo { if err := os.Chdir(repoDir); err == nil { return true, nil } } } fmt.Fprintln(os.Stderr, app.Tr.NoRecentRepositories) os.Exit(1) } // Run this afterward so that the previous repo creation steps can run without this interfering if repoPaths.IsBareRepo() { fmt.Print(app.Tr.BareRepo) response, _ := bufio.NewReader(os.Stdin).ReadString('\n') if shouldOpenRecent := strings.Trim(response, " \r\n") == "y"; !shouldOpenRecent { os.Exit(0) } if didOpenRepo := openRecentRepo(app); didOpenRepo { return true, nil } fmt.Println(app.Tr.NoRecentRepositories) os.Exit(1) } return false, nil } func (app *App) Run(startArgs appTypes.StartArgs) error { err := app.Gui.RunAndHandleError(startArgs) return err } // Close closes any resources func (app *App) Close() error { for _, closer := range app.closers { if err := closer.Close(); err != nil { return err } } return nil } lazygit-0.50.0+ds1/pkg/app/daemon/000077500000000000000000000000001500612110400165345ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/app/daemon/daemon.go000066400000000000000000000237111500612110400203320ustar00rootroot00000000000000package daemon import ( "encoding/json" "fmt" "log" "os" "os/exec" "strconv" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // Sometimes lazygit will be invoked in daemon mode from a parent lazygit process. // We do this when git lets us supply a program to run within a git command. // For example, if we want to ensure that a git command doesn't hang due to // waiting for an editor to save a commit message, we can tell git to invoke lazygit // as the editor via 'GIT_EDITOR=lazygit', and use the env var // 'LAZYGIT_DAEMON_KIND=1' (exit immediately) to specify that we want to run lazygit // as a daemon which simply exits immediately. // // 'Daemon' is not the best name for this, because it's not a persistent background // process, but it's close enough. type DaemonKind int const ( // for when we fail to parse the daemon kind DaemonKindUnknown DaemonKind = iota DaemonKindExitImmediately DaemonKindRemoveUpdateRefsForCopiedBranch DaemonKindMoveTodosUp DaemonKindMoveTodosDown DaemonKindInsertBreak DaemonKindChangeTodoActions DaemonKindDropMergeCommit DaemonKindMoveFixupCommitDown DaemonKindWriteRebaseTodo ) const ( DaemonKindEnvKey string = "LAZYGIT_DAEMON_KIND" // Contains json-encoded arguments to the daemon DaemonInstructionEnvKey string = "LAZYGIT_DAEMON_INSTRUCTION" ) func getInstruction() Instruction { jsonData := os.Getenv(DaemonInstructionEnvKey) mapping := map[DaemonKind]func(string) Instruction{ DaemonKindExitImmediately: deserializeInstruction[*ExitImmediatelyInstruction], DaemonKindRemoveUpdateRefsForCopiedBranch: deserializeInstruction[*RemoveUpdateRefsForCopiedBranchInstruction], DaemonKindChangeTodoActions: deserializeInstruction[*ChangeTodoActionsInstruction], DaemonKindDropMergeCommit: deserializeInstruction[*DropMergeCommitInstruction], DaemonKindMoveFixupCommitDown: deserializeInstruction[*MoveFixupCommitDownInstruction], DaemonKindMoveTodosUp: deserializeInstruction[*MoveTodosUpInstruction], DaemonKindMoveTodosDown: deserializeInstruction[*MoveTodosDownInstruction], DaemonKindInsertBreak: deserializeInstruction[*InsertBreakInstruction], DaemonKindWriteRebaseTodo: deserializeInstruction[*WriteRebaseTodoInstruction], } return mapping[getDaemonKind()](jsonData) } func Handle(common *common.Common) { if !InDaemonMode() { return } instruction := getInstruction() if err := instruction.run(common); err != nil { log.Fatal(err) } os.Exit(0) } func InDaemonMode() bool { return getDaemonKind() != DaemonKindUnknown } func getDaemonKind() DaemonKind { intValue, err := strconv.Atoi(os.Getenv(DaemonKindEnvKey)) if err != nil { return DaemonKindUnknown } return DaemonKind(intValue) } func getCommentChar() byte { cmd := exec.Command("git", "config", "--get", "--null", "core.commentChar") if output, err := cmd.Output(); err == nil && len(output) == 2 { return output[0] } return '#' } // An Instruction is a command to be run by lazygit in daemon mode. // It is serialized to json and passed to lazygit via environment variables type Instruction interface { Kind() DaemonKind SerializedInstructions() string // runs the instruction run(common *common.Common) error } func serializeInstruction[T any](instruction T) string { jsonData, err := json.Marshal(instruction) if err != nil { // this should never happen panic(err) } return string(jsonData) } func deserializeInstruction[T Instruction](jsonData string) Instruction { var instruction T err := json.Unmarshal([]byte(jsonData), &instruction) if err != nil { panic(err) } return instruction } func ToEnvVars(instruction Instruction) []string { return []string{ fmt.Sprintf("%s=%d", DaemonKindEnvKey, instruction.Kind()), fmt.Sprintf("%s=%s", DaemonInstructionEnvKey, instruction.SerializedInstructions()), } } type ExitImmediatelyInstruction struct{} func (self *ExitImmediatelyInstruction) Kind() DaemonKind { return DaemonKindExitImmediately } func (self *ExitImmediatelyInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *ExitImmediatelyInstruction) run(common *common.Common) error { return nil } func NewExitImmediatelyInstruction() Instruction { return &ExitImmediatelyInstruction{} } type RemoveUpdateRefsForCopiedBranchInstruction struct{} func (self *RemoveUpdateRefsForCopiedBranchInstruction) Kind() DaemonKind { return DaemonKindRemoveUpdateRefsForCopiedBranch } func (self *RemoveUpdateRefsForCopiedBranchInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *RemoveUpdateRefsForCopiedBranchInstruction) run(common *common.Common) error { return handleInteractiveRebase(common, func(path string) error { return nil }) } func NewRemoveUpdateRefsForCopiedBranchInstruction() Instruction { return &RemoveUpdateRefsForCopiedBranchInstruction{} } type ChangeTodoActionsInstruction struct { Changes []ChangeTodoAction } func NewChangeTodoActionsInstruction(changes []ChangeTodoAction) Instruction { return &ChangeTodoActionsInstruction{ Changes: changes, } } func (self *ChangeTodoActionsInstruction) Kind() DaemonKind { return DaemonKindChangeTodoActions } func (self *ChangeTodoActionsInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *ChangeTodoActionsInstruction) run(common *common.Common) error { return handleInteractiveRebase(common, func(path string) error { changes := lo.Map(self.Changes, func(c ChangeTodoAction, _ int) utils.TodoChange { return utils.TodoChange{ Hash: c.Hash, NewAction: c.NewAction, } }) return utils.EditRebaseTodo(path, changes, getCommentChar()) }) } type DropMergeCommitInstruction struct { Hash string } func NewDropMergeCommitInstruction(hash string) Instruction { return &DropMergeCommitInstruction{ Hash: hash, } } func (self *DropMergeCommitInstruction) Kind() DaemonKind { return DaemonKindDropMergeCommit } func (self *DropMergeCommitInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *DropMergeCommitInstruction) run(common *common.Common) error { return handleInteractiveRebase(common, func(path string) error { return utils.DropMergeCommit(path, self.Hash, getCommentChar()) }) } // Takes the hash of some commit, and the hash of a fixup commit that was created // at the end of the branch, then moves the fixup commit down to right after the // original commit, changing its type to "fixup" (only if ChangeToFixup is true) type MoveFixupCommitDownInstruction struct { OriginalHash string FixupHash string ChangeToFixup bool } func NewMoveFixupCommitDownInstruction(originalHash string, fixupHash string, changeToFixup bool) Instruction { return &MoveFixupCommitDownInstruction{ OriginalHash: originalHash, FixupHash: fixupHash, ChangeToFixup: changeToFixup, } } func (self *MoveFixupCommitDownInstruction) Kind() DaemonKind { return DaemonKindMoveFixupCommitDown } func (self *MoveFixupCommitDownInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error { return handleInteractiveRebase(common, func(path string) error { return utils.MoveFixupCommitDown(path, self.OriginalHash, self.FixupHash, self.ChangeToFixup, getCommentChar()) }) } type MoveTodosUpInstruction struct { Hashes []string } func NewMoveTodosUpInstruction(hashes []string) Instruction { return &MoveTodosUpInstruction{ Hashes: hashes, } } func (self *MoveTodosUpInstruction) Kind() DaemonKind { return DaemonKindMoveTodosUp } func (self *MoveTodosUpInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *MoveTodosUpInstruction) run(common *common.Common) error { todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo { return utils.Todo{ Hash: hash, } }) return handleInteractiveRebase(common, func(path string) error { return utils.MoveTodosUp(path, todosToMove, false, getCommentChar()) }) } type MoveTodosDownInstruction struct { Hashes []string } func NewMoveTodosDownInstruction(hashes []string) Instruction { return &MoveTodosDownInstruction{ Hashes: hashes, } } func (self *MoveTodosDownInstruction) Kind() DaemonKind { return DaemonKindMoveTodosDown } func (self *MoveTodosDownInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *MoveTodosDownInstruction) run(common *common.Common) error { todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo { return utils.Todo{ Hash: hash, } }) return handleInteractiveRebase(common, func(path string) error { return utils.MoveTodosDown(path, todosToMove, false, getCommentChar()) }) } type InsertBreakInstruction struct{} func NewInsertBreakInstruction() Instruction { return &InsertBreakInstruction{} } func (self *InsertBreakInstruction) Kind() DaemonKind { return DaemonKindInsertBreak } func (self *InsertBreakInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *InsertBreakInstruction) run(common *common.Common) error { return handleInteractiveRebase(common, func(path string) error { return utils.PrependStrToTodoFile(path, []byte("break\n")) }) } type WriteRebaseTodoInstruction struct { TodosFileContent []byte } func NewWriteRebaseTodoInstruction(todosFileContent []byte) Instruction { return &WriteRebaseTodoInstruction{ TodosFileContent: todosFileContent, } } func (self *WriteRebaseTodoInstruction) Kind() DaemonKind { return DaemonKindWriteRebaseTodo } func (self *WriteRebaseTodoInstruction) SerializedInstructions() string { return serializeInstruction(self) } func (self *WriteRebaseTodoInstruction) run(common *common.Common) error { return handleInteractiveRebase(common, func(path string) error { return os.WriteFile(path, self.TodosFileContent, 0o644) }) } lazygit-0.50.0+ds1/pkg/app/daemon/rebase.go000066400000000000000000000032121500612110400203220ustar00rootroot00000000000000package daemon import ( "os" "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) type TodoLine struct { Action string Commit *models.Commit } func (self *TodoLine) ToString() string { if self.Action == "break" { return self.Action + "\n" } else { return self.Action + " " + self.Commit.Hash() + " " + self.Commit.Name + "\n" } } func TodoLinesToString(todoLines []TodoLine) string { lines := lo.Map(todoLines, func(todoLine TodoLine, _ int) string { return todoLine.ToString() }) return strings.Join(lo.Reverse(lines), "") } type ChangeTodoAction struct { Hash string NewAction todo.TodoCommand } func handleInteractiveRebase(common *common.Common, f func(path string) error) error { common.Log.Info("Lazygit invoked as interactive rebase demon") common.Log.Info("args: ", os.Args) path := os.Args[1] if strings.HasSuffix(path, "git-rebase-todo") { err := utils.RemoveUpdateRefsForCopiedBranch(path, getCommentChar()) if err != nil { return err } return f(path) } else if strings.HasSuffix(path, filepath.Join(gitDir(), "COMMIT_EDITMSG")) { // TODO: test // if we are rebasing and squashing, we'll see a COMMIT_EDITMSG // but in this case we don't need to edit it, so we'll just return } else { common.Log.Info("Lazygit demon did not match on any use cases") } return nil } func gitDir() string { dir := env.GetGitDirEnv() if dir == "" { return ".git" } return dir } lazygit-0.50.0+ds1/pkg/app/entry_point.go000066400000000000000000000221011500612110400201660ustar00rootroot00000000000000package app import ( "bytes" "fmt" "log" "net/http" _ "net/http/pprof" "os" "os/exec" "path/filepath" "runtime" "runtime/debug" "strings" "github.com/integrii/flaggy" "github.com/jesseduffield/lazygit/pkg/app/daemon" appTypes "github.com/jesseduffield/lazygit/pkg/app/types" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/env" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" "github.com/jesseduffield/lazygit/pkg/logs/tail" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "gopkg.in/yaml.v3" ) type cliArgs struct { RepoPath string FilterPath string GitArg string UseConfigDir string WorkTree string GitDir string CustomConfigFile string ScreenMode string PrintVersionInfo bool Debug bool TailLogs bool Profile bool PrintDefaultConfig bool PrintConfigDir bool } type BuildInfo struct { Commit string Date string Version string BuildSource string } func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTest) { cliArgs := parseCliArgsAndEnvVars() mergeBuildInfo(buildInfo) if cliArgs.RepoPath != "" { if cliArgs.WorkTree != "" || cliArgs.GitDir != "" { log.Fatal("--path option is incompatible with the --work-tree and --git-dir options") } absRepoPath, err := filepath.Abs(cliArgs.RepoPath) if err != nil { log.Fatal(err) } if isRepo, err := isDirectoryAGitRepository(absRepoPath); err != nil || !isRepo { log.Fatal(absRepoPath + " is not a valid git repository.") } cliArgs.GitDir = filepath.Join(absRepoPath, ".git") err = os.Chdir(absRepoPath) if err != nil { log.Fatalf("Failed to change directory to %s: %v", absRepoPath, err) } } else if cliArgs.WorkTree != "" { env.SetWorkTreeEnv(cliArgs.WorkTree) if err := os.Chdir(cliArgs.WorkTree); err != nil { log.Fatalf("Failed to change directory to %s: %v", cliArgs.WorkTree, err) } } if cliArgs.CustomConfigFile != "" { os.Setenv("LG_CONFIG_FILE", cliArgs.CustomConfigFile) } if cliArgs.UseConfigDir != "" { os.Setenv("CONFIG_DIR", cliArgs.UseConfigDir) } if cliArgs.GitDir != "" { env.SetGitDirEnv(cliArgs.GitDir) } if cliArgs.PrintVersionInfo { gitVersion := getGitVersionInfo() fmt.Printf("commit=%s, build date=%s, build source=%s, version=%s, os=%s, arch=%s, git version=%s\n", buildInfo.Commit, buildInfo.Date, buildInfo.BuildSource, buildInfo.Version, runtime.GOOS, runtime.GOARCH, gitVersion) os.Exit(0) } if cliArgs.PrintDefaultConfig { var buf bytes.Buffer encoder := yaml.NewEncoder(&buf) err := encoder.Encode(config.GetDefaultConfig()) if err != nil { log.Fatal(err.Error()) } fmt.Printf("%s\n", buf.String()) os.Exit(0) } if cliArgs.PrintConfigDir { fmt.Printf("%s\n", config.ConfigDir()) os.Exit(0) } if cliArgs.TailLogs { logPath, err := config.LogPath() if err != nil { log.Fatal(err.Error()) } tail.TailLogs(logPath) os.Exit(0) } tempDir, err := os.MkdirTemp("", "lazygit-*") if err != nil { log.Fatal(err.Error()) } defer os.RemoveAll(tempDir) appConfig, err := config.NewAppConfig("lazygit", buildInfo.Version, buildInfo.Commit, buildInfo.Date, buildInfo.BuildSource, cliArgs.Debug, tempDir) if err != nil { log.Fatal(err.Error()) } if integrationTest != nil { integrationTest.SetupConfig(appConfig) // Preserve the changes that the test setup just made to the config, so // they don't get lost when we reload the config while running the test // (which happens when switching between repos, going in and out of // submodules, etc). appConfig.SaveGlobalUserConfig() } common, err := NewCommon(appConfig) if err != nil { log.Fatal(err) } if daemon.InDaemonMode() { daemon.Handle(common) return } if cliArgs.Profile { go func() { if err := http.ListenAndServe("localhost:6060", nil); err != nil { log.Fatal(err) } }() } parsedGitArg := parseGitArg(cliArgs.GitArg) Run(appConfig, common, appTypes.NewStartArgs(cliArgs.FilterPath, parsedGitArg, cliArgs.ScreenMode, integrationTest)) } func parseCliArgsAndEnvVars() *cliArgs { flaggy.DefaultParser.ShowVersionWithVersionFlag = false repoPath := "" flaggy.String(&repoPath, "p", "path", "Path of git repo. (equivalent to --work-tree= --git-dir=/.git/)") filterPath := "" flaggy.String(&filterPath, "f", "filter", "Path to filter on in `git log -- `. When in filter mode, the commits, reflog, and stash are filtered based on the given path, and some operations are restricted") gitArg := "" flaggy.AddPositionalValue(&gitArg, "git-arg", 1, false, "Panel to focus upon opening lazygit. Accepted values (based on git terminology): status, branch, log, stash. Ignored if --filter arg is passed.") printVersionInfo := false flaggy.Bool(&printVersionInfo, "v", "version", "Print the current version") debug := false flaggy.Bool(&debug, "d", "debug", "Run in debug mode with logging (see --logs flag below). Use the LOG_LEVEL env var to set the log level (debug/info/warn/error)") tailLogs := false flaggy.Bool(&tailLogs, "l", "logs", "Tail lazygit logs (intended to be used when `lazygit --debug` is called in a separate terminal tab)") profile := false flaggy.Bool(&profile, "", "profile", "Start the profiler and serve it on http port 6060. See CONTRIBUTING.md for more info.") printDefaultConfig := false flaggy.Bool(&printDefaultConfig, "c", "config", "Print the default config") printConfigDir := false flaggy.Bool(&printConfigDir, "cd", "print-config-dir", "Print the config directory") useConfigDir := "" flaggy.String(&useConfigDir, "ucd", "use-config-dir", "override default config directory with provided directory") workTree := os.Getenv("GIT_WORK_TREE") flaggy.String(&workTree, "w", "work-tree", "equivalent of the --work-tree git argument") gitDir := os.Getenv("GIT_DIR") flaggy.String(&gitDir, "g", "git-dir", "equivalent of the --git-dir git argument") customConfigFile := "" flaggy.String(&customConfigFile, "ucf", "use-config-file", "Comma separated list to custom config file(s)") screenMode := "" flaggy.String(&screenMode, "sm", "screen-mode", "The initial screen-mode, which determines the size of the focused panel. Valid options: 'normal' (default), 'half', 'full'") flaggy.Parse() if os.Getenv("DEBUG") == "TRUE" { debug = true } return &cliArgs{ RepoPath: repoPath, FilterPath: filterPath, GitArg: gitArg, PrintVersionInfo: printVersionInfo, Debug: debug, TailLogs: tailLogs, Profile: profile, PrintDefaultConfig: printDefaultConfig, PrintConfigDir: printConfigDir, UseConfigDir: useConfigDir, WorkTree: workTree, GitDir: gitDir, CustomConfigFile: customConfigFile, ScreenMode: screenMode, } } func parseGitArg(gitArg string) appTypes.GitArg { typedArg := appTypes.GitArg(gitArg) // using switch so that linter catches when a new git arg value is defined but not handled here switch typedArg { case appTypes.GitArgNone, appTypes.GitArgStatus, appTypes.GitArgBranch, appTypes.GitArgLog, appTypes.GitArgStash: return typedArg } permittedValues := []string{ string(appTypes.GitArgStatus), string(appTypes.GitArgBranch), string(appTypes.GitArgLog), string(appTypes.GitArgStash), } log.Fatalf("Invalid git arg value: '%s'. Must be one of the following values: %s. e.g. 'lazygit status'. See 'lazygit --help'.", gitArg, strings.Join(permittedValues, ", "), ) panic("unreachable") } // the buildInfo struct we get passed in is based on what's baked into the lazygit // binary via the LDFLAGS argument. Some lazygit distributions will make use of these // arguments and some will not. Go recently started baking in build info // into the binary by default e.g. the git commit hash. So in this function // we merge the two together, giving priority to the stuff set by LDFLAGS. // Note: this mutates the argument passed in func mergeBuildInfo(buildInfo *BuildInfo) { // if the version has already been set by build flags then we'll honour that. // chances are it's something like v0.31.0 which is more informative than a // commit hash. if buildInfo.Version != "" { return } buildInfo.Version = "unversioned" goBuildInfo, ok := debug.ReadBuildInfo() if !ok { return } revision, ok := lo.Find(goBuildInfo.Settings, func(setting debug.BuildSetting) bool { return setting.Key == "vcs.revision" }) if ok { buildInfo.Commit = revision.Value // if lazygit was built from source we'll show the version as the // abbreviated commit hash buildInfo.Version = utils.ShortHash(revision.Value) } // if version hasn't been set we assume that neither has the date time, ok := lo.Find(goBuildInfo.Settings, func(setting debug.BuildSetting) bool { return setting.Key == "vcs.time" }) if ok { buildInfo.Date = time.Value } } func getGitVersionInfo() string { cmd := exec.Command("git", "--version") stdout, _ := cmd.Output() gitVersion := strings.Trim(strings.TrimPrefix(string(stdout), "git version "), " \r\n") return gitVersion } lazygit-0.50.0+ds1/pkg/app/errors.go000066400000000000000000000020151500612110400171320ustar00rootroot00000000000000package app import ( "strings" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" ) type errorMapping struct { originalError string newError string } // knownError takes an error and tells us whether it's an error that we know about where we can print a nicely formatted version of it rather than panicking with a stack trace func knownError(tr *i18n.TranslationSet, err error) (string, bool) { errorMessage := err.Error() knownErrorMessages := []string{tr.MinGitVersionError} if lo.Contains(knownErrorMessages, errorMessage) { return errorMessage, true } mappings := []errorMapping{ { originalError: "fatal: not a git repository", newError: tr.NotARepository, }, { originalError: "getwd: no such file or directory", newError: tr.WorkingDirectoryDoesNotExist, }, } if mapping, ok := lo.Find(mappings, func(mapping errorMapping) bool { return strings.Contains(errorMessage, mapping.originalError) }); ok { return mapping.newError, true } return "", false } lazygit-0.50.0+ds1/pkg/app/types/000077500000000000000000000000001500612110400164355ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/app/types/types.go000066400000000000000000000020761500612110400201350ustar00rootroot00000000000000package app import ( integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) // StartArgs is the struct that represents some things we want to do on program start type StartArgs struct { // GitArg determines what context we open in GitArg GitArg // integration test (only relevant when invoking lazygit in the context of an integration test) IntegrationTest integrationTypes.IntegrationTest // FilterPath determines which path we're going to filter on so that we only see commits from that file. FilterPath string // ScreenMode determines the initial Screen Mode (normal, half or full) to use ScreenMode string } type GitArg string const ( GitArgNone GitArg = "" GitArgStatus GitArg = "status" GitArgBranch GitArg = "branch" GitArgLog GitArg = "log" GitArgStash GitArg = "stash" ) func NewStartArgs(filterPath string, gitArg GitArg, screenMode string, test integrationTypes.IntegrationTest) StartArgs { return StartArgs{ FilterPath: filterPath, GitArg: gitArg, ScreenMode: screenMode, IntegrationTest: test, } } lazygit-0.50.0+ds1/pkg/cheatsheet/000077500000000000000000000000001500612110400166265ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/cheatsheet/generate.go000066400000000000000000000147511500612110400207570ustar00rootroot00000000000000//go:generate go run generator.go // This "script" generates files called Keybindings_{{.LANG}}.md // in the docs/keybindings directory. // // The content of these generated files is a keybindings cheatsheet. // // To generate the cheatsheets, run: // go generate pkg/cheatsheet/generate.go package cheatsheet import ( "cmp" "fmt" "log" "os" "slices" "strings" "github.com/jesseduffield/generics/maps" "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/app" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" ) type bindingSection struct { title string bindings []*types.Binding } type header struct { // priority decides the order of the headers in the cheatsheet (lower means higher) priority int title string } type headerWithBindings struct { header header bindings []*types.Binding } func CommandToRun() string { return "go generate ./..." } func GetKeybindingsDir() string { return utils.GetLazyRootDirectory() + "/docs/keybindings" } func generateAtDir(cheatsheetDir string) { translationSetsByLang, err := i18n.GetTranslationSets() if err != nil { log.Fatal(err) } mConfig := config.NewDummyAppConfig() for lang := range translationSetsByLang { mConfig.GetUserConfig().Gui.Language = lang common, err := app.NewCommon(mConfig) if err != nil { log.Fatal(err) } tr, err := i18n.NewTranslationSetFromConfig(common.Log, lang) if err != nil { log.Fatal(err) } common.Tr = tr mApp, _ := app.NewApp(mConfig, nil, common) path := cheatsheetDir + "/Keybindings_" + lang + ".md" file, err := os.Create(path) if err != nil { panic(err) } bindings := mApp.Gui.GetCheatsheetKeybindings() bindingSections := getBindingSections(bindings, mApp.Tr) content := formatSections(mApp.Tr, bindingSections) content = fmt.Sprintf("_This file is auto-generated. To update, make the changes in the "+ "pkg/i18n directory and then run `%s` from the project root._\n\n%s", CommandToRun(), content) writeString(file, content) } } func Generate() { generateAtDir(GetKeybindingsDir()) } func writeString(file *os.File, str string) { _, err := file.WriteString(str) if err != nil { log.Fatal(err) } } func localisedTitle(tr *i18n.TranslationSet, str string) string { contextTitleMap := map[string]string{ "global": tr.GlobalTitle, "navigation": tr.NavigationTitle, "branches": tr.BranchesTitle, "localBranches": tr.LocalBranchesTitle, "files": tr.FilesTitle, "status": tr.StatusTitle, "submodules": tr.SubmodulesTitle, "subCommits": tr.SubCommitsTitle, "remoteBranches": tr.RemoteBranchesTitle, "remotes": tr.RemotesTitle, "reflogCommits": tr.ReflogCommitsTitle, "tags": tr.TagsTitle, "commitFiles": tr.CommitFilesTitle, "commitMessage": tr.CommitSummaryTitle, "commitDescription": tr.CommitDescriptionTitle, "commits": tr.CommitsTitle, "confirmation": tr.ConfirmationTitle, "information": tr.InformationTitle, "main": tr.NormalTitle, "patchBuilding": tr.PatchBuildingTitle, "mergeConflicts": tr.MergingTitle, "staging": tr.StagingTitle, "menu": tr.MenuTitle, "search": tr.SearchTitle, "secondary": tr.SecondaryTitle, "stash": tr.StashTitle, "suggestions": tr.SuggestionsCheatsheetTitle, "extras": tr.ExtrasTitle, "worktrees": tr.WorktreesTitle, } title, ok := contextTitleMap[str] if !ok { panic(fmt.Sprintf("title not found for %s", str)) } return title } func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*bindingSection { excludedViews := []string{"stagingSecondary", "patchBuildingSecondary"} bindingsToDisplay := lo.Filter(bindings, func(binding *types.Binding, _ int) bool { if lo.Contains(excludedViews, binding.ViewName) { return false } return (binding.Description != "" || binding.Alternative != "") && binding.Key != nil }) bindingsByHeader := lo.GroupBy(bindingsToDisplay, func(binding *types.Binding) header { return getHeader(binding, tr) }) bindingGroups := maps.MapToSlice( bindingsByHeader, func(header header, hBindings []*types.Binding) headerWithBindings { uniqBindings := lo.UniqBy(hBindings, func(binding *types.Binding) string { return binding.Description + keybindings.LabelFromKey(binding.Key) }) return headerWithBindings{ header: header, bindings: uniqBindings, } }, ) slices.SortFunc(bindingGroups, func(a, b headerWithBindings) int { if a.header.priority != b.header.priority { return cmp.Compare(b.header.priority, a.header.priority) } return strings.Compare(a.header.title, b.header.title) }) return lo.Map(bindingGroups, func(hb headerWithBindings, _ int) *bindingSection { return &bindingSection{ title: hb.header.title, bindings: hb.bindings, } }) } func getHeader(binding *types.Binding, tr *i18n.TranslationSet) header { if binding.Tag == "navigation" { return header{priority: 2, title: localisedTitle(tr, "navigation")} } if binding.ViewName == "" { return header{priority: 3, title: localisedTitle(tr, "global")} } return header{priority: 1, title: localisedTitle(tr, binding.ViewName)} } func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection) string { content := fmt.Sprintf("# Lazygit %s\n", tr.Keybindings) content += fmt.Sprintf("\n%s\n", italicize(tr.KeybindingsLegend)) for _, section := range bindingSections { content += formatTitle(section.title) content += "| Key | Action | Info |\n" content += "|-----|--------|-------------|\n" for _, binding := range section.bindings { content += formatBinding(binding) } } return content } func formatTitle(title string) string { return fmt.Sprintf("\n## %s\n\n", title) } func formatBinding(binding *types.Binding) string { action := keybindings.LabelFromKey(binding.Key) description := binding.Description if binding.Alternative != "" { action += fmt.Sprintf(" (%s)", binding.Alternative) } // Use backticks for keyboard keys. Two backticks are needed with an inner space // to escape a key that is itself a backtick. return fmt.Sprintf("| `` %s `` | %s | %s |\n", action, description, binding.Tooltip) } func italicize(str string) string { return fmt.Sprintf("_%s_", str) } lazygit-0.50.0+ds1/pkg/cheatsheet/generate_test.go000066400000000000000000000121061500612110400220060ustar00rootroot00000000000000package cheatsheet import ( "testing" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/stretchr/testify/assert" ) func TestGetBindingSections(t *testing.T) { tr := i18n.EnglishTranslationSet() tests := []struct { testName string bindings []*types.Binding expected []*bindingSection }{ { testName: "no bindings", bindings: []*types.Binding{}, expected: []*bindingSection{}, }, { testName: "one binding", bindings: []*types.Binding{ { ViewName: "files", Description: "stage file", Key: 'a', }, }, expected: []*bindingSection{ { title: "Files", bindings: []*types.Binding{ { ViewName: "files", Description: "stage file", Key: 'a', }, }, }, }, }, { testName: "global binding", bindings: []*types.Binding{ { ViewName: "", Description: "quit", Key: 'a', }, }, expected: []*bindingSection{ { title: "Global keybindings", bindings: []*types.Binding{ { ViewName: "", Description: "quit", Key: 'a', }, }, }, }, }, { testName: "grouped bindings", bindings: []*types.Binding{ { ViewName: "files", Description: "stage file", Key: 'a', }, { ViewName: "files", Description: "unstage file", Key: 'a', }, { ViewName: "submodules", Description: "drop submodule", Key: 'a', }, }, expected: []*bindingSection{ { title: "Files", bindings: []*types.Binding{ { ViewName: "files", Description: "stage file", Key: 'a', }, { ViewName: "files", Description: "unstage file", Key: 'a', }, }, }, { title: "Submodules", bindings: []*types.Binding{ { ViewName: "submodules", Description: "drop submodule", Key: 'a', }, }, }, }, }, { testName: "with navigation bindings", bindings: []*types.Binding{ { ViewName: "files", Description: "stage file", Key: 'a', }, { ViewName: "files", Description: "unstage file", Key: 'a', }, { ViewName: "files", Description: "scroll", Key: 'a', Tag: "navigation", }, { ViewName: "commits", Description: "revert commit", Key: 'a', }, }, expected: []*bindingSection{ { title: "List panel navigation", bindings: []*types.Binding{ { ViewName: "files", Description: "scroll", Key: 'a', Tag: "navigation", }, }, }, { title: "Commits", bindings: []*types.Binding{ { ViewName: "commits", Description: "revert commit", Key: 'a', }, }, }, { title: "Files", bindings: []*types.Binding{ { ViewName: "files", Description: "stage file", Key: 'a', }, { ViewName: "files", Description: "unstage file", Key: 'a', }, }, }, }, }, { testName: "with duplicate navigation bindings", bindings: []*types.Binding{ { ViewName: "files", Description: "stage file", Key: 'a', }, { ViewName: "files", Description: "unstage file", Key: 'a', }, { ViewName: "files", Description: "scroll", Key: 'a', Tag: "navigation", }, { ViewName: "commits", Description: "revert commit", Key: 'a', }, { ViewName: "commits", Description: "scroll", Key: 'a', Tag: "navigation", }, { ViewName: "commits", Description: "page up", Key: 'a', Tag: "navigation", }, }, expected: []*bindingSection{ { title: "List panel navigation", bindings: []*types.Binding{ { ViewName: "files", Description: "scroll", Key: 'a', Tag: "navigation", }, { ViewName: "commits", Description: "page up", Key: 'a', Tag: "navigation", }, }, }, { title: "Commits", bindings: []*types.Binding{ { ViewName: "commits", Description: "revert commit", Key: 'a', }, }, }, { title: "Files", bindings: []*types.Binding{ { ViewName: "files", Description: "stage file", Key: 'a', }, { ViewName: "files", Description: "unstage file", Key: 'a', }, }, }, }, }, } for _, test := range tests { t.Run(test.testName, func(t *testing.T) { actual := getBindingSections(test.bindings, tr) assert.EqualValues(t, test.expected, actual) }) } } lazygit-0.50.0+ds1/pkg/cheatsheet/generator.go000066400000000000000000000003401500612110400211400ustar00rootroot00000000000000//go:build ignore package main import ( "fmt" "github.com/jesseduffield/lazygit/pkg/cheatsheet" ) func main() { fmt.Printf("Generating cheatsheets in %s...\n", cheatsheet.GetKeybindingsDir()) cheatsheet.Generate() } lazygit-0.50.0+ds1/pkg/commands/000077500000000000000000000000001500612110400163125ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/git.go000066400000000000000000000153301500612110400174260ustar00rootroot00000000000000package commands import ( "os" "strings" "github.com/go-errors/errors" gogit "github.com/jesseduffield/go-git/v5" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" ) // GitCommand is our main git interface type GitCommand struct { Blame *git_commands.BlameCommands Branch *git_commands.BranchCommands Commit *git_commands.CommitCommands Config *git_commands.ConfigCommands Custom *git_commands.CustomCommands Diff *git_commands.DiffCommands File *git_commands.FileCommands Flow *git_commands.FlowCommands Patch *git_commands.PatchCommands Rebase *git_commands.RebaseCommands Remote *git_commands.RemoteCommands Stash *git_commands.StashCommands Status *git_commands.StatusCommands Submodule *git_commands.SubmoduleCommands Sync *git_commands.SyncCommands Tag *git_commands.TagCommands WorkingTree *git_commands.WorkingTreeCommands Bisect *git_commands.BisectCommands Worktree *git_commands.WorktreeCommands Version *git_commands.GitVersion RepoPaths *git_commands.RepoPaths Loaders Loaders } type Loaders struct { BranchLoader *git_commands.BranchLoader CommitFileLoader *git_commands.CommitFileLoader CommitLoader *git_commands.CommitLoader FileLoader *git_commands.FileLoader ReflogCommitLoader *git_commands.ReflogCommitLoader RemoteLoader *git_commands.RemoteLoader StashLoader *git_commands.StashLoader TagLoader *git_commands.TagLoader Worktrees *git_commands.WorktreeLoader } func NewGitCommand( cmn *common.Common, version *git_commands.GitVersion, osCommand *oscommands.OSCommand, gitConfig git_config.IGitConfig, ) (*GitCommand, error) { repoPaths, err := git_commands.GetRepoPaths(osCommand.Cmd, version) if err != nil { return nil, errors.Errorf("Error getting repo paths: %v", err) } err = os.Chdir(repoPaths.WorktreePath()) if err != nil { return nil, utils.WrapError(err) } repository, err := gogit.PlainOpenWithOptions( repoPaths.WorktreeGitDirPath(), &gogit.PlainOpenOptions{DetectDotGit: false, EnableDotGitCommonDir: true}, ) if err != nil { if strings.Contains(err.Error(), `unquoted '\' must be followed by new line`) { return nil, errors.New(cmn.Tr.GitconfigParseErr) } return nil, err } return NewGitCommandAux( cmn, version, osCommand, gitConfig, repoPaths, repository, ), nil } func NewGitCommandAux( cmn *common.Common, version *git_commands.GitVersion, osCommand *oscommands.OSCommand, gitConfig git_config.IGitConfig, repoPaths *git_commands.RepoPaths, repo *gogit.Repository, ) *GitCommand { cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd) // here we're doing a bunch of dependency injection for each of our commands structs. // This is admittedly messy, but allows us to test each command struct in isolation, // and allows for better namespacing when compared to having every method living // on the one struct. // common ones are: cmn, osCommand, dotGitDir, configCommands configCommands := git_commands.NewConfigCommands(cmn, gitConfig, repo) gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, repo, configCommands) fileLoader := git_commands.NewFileLoader(gitCommon, cmd, configCommands) statusCommands := git_commands.NewStatusCommands(gitCommon) flowCommands := git_commands.NewFlowCommands(gitCommon) remoteCommands := git_commands.NewRemoteCommands(gitCommon) branchCommands := git_commands.NewBranchCommands(gitCommon) syncCommands := git_commands.NewSyncCommands(gitCommon) tagCommands := git_commands.NewTagCommands(gitCommon) commitCommands := git_commands.NewCommitCommands(gitCommon) customCommands := git_commands.NewCustomCommands(gitCommon) diffCommands := git_commands.NewDiffCommands(gitCommon) fileCommands := git_commands.NewFileCommands(gitCommon) submoduleCommands := git_commands.NewSubmoduleCommands(gitCommon) workingTreeCommands := git_commands.NewWorkingTreeCommands(gitCommon, submoduleCommands, fileLoader) rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands) stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands) patchBuilder := patch.NewPatchBuilder(cmn.Log, func(from string, to string, reverse bool, filename string, plain bool) (string, error) { return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, plain) }) patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder) bisectCommands := git_commands.NewBisectCommands(gitCommon) worktreeCommands := git_commands.NewWorktreeCommands(gitCommon) blameCommands := git_commands.NewBlameCommands(gitCommon) branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands) commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd) commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon) reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd) remoteLoader := git_commands.NewRemoteLoader(cmn, cmd, repo.Remotes) worktreeLoader := git_commands.NewWorktreeLoader(gitCommon) stashLoader := git_commands.NewStashLoader(cmn, cmd) tagLoader := git_commands.NewTagLoader(cmn, cmd) return &GitCommand{ Blame: blameCommands, Branch: branchCommands, Commit: commitCommands, Config: configCommands, Custom: customCommands, Diff: diffCommands, File: fileCommands, Flow: flowCommands, Patch: patchCommands, Rebase: rebaseCommands, Remote: remoteCommands, Stash: stashCommands, Status: statusCommands, Submodule: submoduleCommands, Sync: syncCommands, Tag: tagCommands, Bisect: bisectCommands, WorkingTree: workingTreeCommands, Worktree: worktreeCommands, Version: version, Loaders: Loaders{ BranchLoader: branchLoader, CommitFileLoader: commitFileLoader, CommitLoader: commitLoader, FileLoader: fileLoader, ReflogCommitLoader: reflogCommitLoader, RemoteLoader: remoteLoader, Worktrees: worktreeLoader, StashLoader: stashLoader, TagLoader: tagLoader, }, RepoPaths: repoPaths, } } func VerifyInGitRepo(osCommand *oscommands.OSCommand) error { return osCommand.Cmd.New(git_commands.NewGitCmd("rev-parse").Arg("--git-dir").ToArgv()).DontLog().Run() } lazygit-0.50.0+ds1/pkg/commands/git_cmd_obj_builder.go000066400000000000000000000032701500612110400226110ustar00rootroot00000000000000package commands import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/sirupsen/logrus" ) // all we're doing here is wrapping the default command object builder with // some git-specific stuff: e.g. adding a git-specific env var type gitCmdObjBuilder struct { innerBuilder *oscommands.CmdObjBuilder } var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *gitCmdObjBuilder { // the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase) updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { return &gitCmdObjRunner{ log: log, innerRunner: runner, } }) return &gitCmdObjBuilder{ innerBuilder: updatedBuilder, } } var defaultEnvVar = "GIT_OPTIONAL_LOCKS=0" func (self *gitCmdObjBuilder) New(args []string) oscommands.ICmdObj { return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar) } func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) oscommands.ICmdObj { return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar) } func (self *gitCmdObjBuilder) Quote(str string) string { return self.innerBuilder.Quote(str) } lazygit-0.50.0+ds1/pkg/commands/git_cmd_obj_runner.go000066400000000000000000000040021500612110400224660ustar00rootroot00000000000000package commands import ( "strings" "time" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/sirupsen/logrus" ) // here we're wrapping the default command runner in some git-specific stuff e.g. retry logic if we get an error due to the presence of .git/index.lock const ( WaitTime = 50 * time.Millisecond RetryCount = 5 ) type gitCmdObjRunner struct { log *logrus.Entry innerRunner oscommands.ICmdObjRunner } func (self *gitCmdObjRunner) Run(cmdObj oscommands.ICmdObj) error { _, err := self.RunWithOutput(cmdObj) return err } func (self *gitCmdObjRunner) RunWithOutput(cmdObj oscommands.ICmdObj) (string, error) { var output string var err error for i := 0; i < RetryCount; i++ { newCmdObj := cmdObj.Clone() output, err = self.innerRunner.RunWithOutput(newCmdObj) if err == nil || !strings.Contains(output, ".git/index.lock") { return output, err } // if we have an error based on the index lock, we should wait a bit and then retry self.log.Warn("index.lock prevented command from running. Retrying command after a small wait") time.Sleep(WaitTime) } return output, err } func (self *gitCmdObjRunner) RunWithOutputs(cmdObj oscommands.ICmdObj) (string, string, error) { var stdout, stderr string var err error for i := 0; i < RetryCount; i++ { newCmdObj := cmdObj.Clone() stdout, stderr, err = self.innerRunner.RunWithOutputs(newCmdObj) if err == nil || !strings.Contains(stdout+stderr, ".git/index.lock") { return stdout, stderr, err } // if we have an error based on the index lock, we should wait a bit and then retry self.log.Warn("index.lock prevented command from running. Retrying command after a small wait") time.Sleep(WaitTime) } return stdout, stderr, err } // Retry logic not implemented here, but these commands typically don't need to obtain a lock. func (self *gitCmdObjRunner) RunAndProcessLines(cmdObj oscommands.ICmdObj, onLine func(line string) (bool, error)) error { return self.innerRunner.RunAndProcessLines(cmdObj, onLine) } lazygit-0.50.0+ds1/pkg/commands/git_commands/000077500000000000000000000000001500612110400207565ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/git_commands/bisect.go000066400000000000000000000121161500612110400225570ustar00rootroot00000000000000package git_commands import ( "os" "path/filepath" "strings" ) type BisectCommands struct { *GitCommon } func NewBisectCommands(gitCommon *GitCommon) *BisectCommands { return &BisectCommands{ GitCommon: gitCommon, } } // This command is pretty cheap to run so we're not storing the result anywhere. // But if it becomes problematic we can chang that. func (self *BisectCommands) GetInfo() *BisectInfo { return self.GetInfoForGitDir(self.repoPaths.WorktreeGitDirPath()) } func (self *BisectCommands) GetInfoForGitDir(gitDir string) *BisectInfo { var err error info := &BisectInfo{started: false, log: self.Log, newTerm: "bad", oldTerm: "good"} // we return nil if we're not in a git bisect session. // we know we're in a session by the presence of a .git/BISECT_START file bisectStartPath := filepath.Join(gitDir, "BISECT_START") exists, err := self.os.FileExists(bisectStartPath) if err != nil { self.Log.Infof("error getting git bisect info: %s", err.Error()) return info } if !exists { return info } startContent, err := os.ReadFile(bisectStartPath) if err != nil { self.Log.Infof("error getting git bisect info: %s", err.Error()) return info } info.started = true info.start = strings.TrimSpace(string(startContent)) termsContent, err := os.ReadFile(filepath.Join(gitDir, "BISECT_TERMS")) if err != nil { // old git versions won't have this file so we default to bad/good } else { splitContent := strings.Split(string(termsContent), "\n") info.newTerm = splitContent[0] info.oldTerm = splitContent[1] } bisectRefsDir := filepath.Join(gitDir, "refs", "bisect") files, err := os.ReadDir(bisectRefsDir) if err != nil { self.Log.Infof("error getting git bisect info: %s", err.Error()) return info } info.statusMap = make(map[string]BisectStatus) for _, file := range files { status := BisectStatusSkipped name := file.Name() path := filepath.Join(bisectRefsDir, name) fileContent, err := os.ReadFile(path) if err != nil { self.Log.Infof("error getting git bisect info: %s", err.Error()) return info } hash := strings.TrimSpace(string(fileContent)) if name == info.newTerm { status = BisectStatusNew } else if strings.HasPrefix(name, info.oldTerm+"-") { status = BisectStatusOld } else if strings.HasPrefix(name, "skipped-") { status = BisectStatusSkipped } info.statusMap[hash] = status } currentContent, err := os.ReadFile(filepath.Join(gitDir, "BISECT_EXPECTED_REV")) if err != nil { self.Log.Infof("error getting git bisect info: %s", err.Error()) return info } currentHash := strings.TrimSpace(string(currentContent)) info.current = currentHash return info } func (self *BisectCommands) Reset() error { cmdArgs := NewGitCmd("bisect").Arg("reset").ToArgv() return self.cmd.New(cmdArgs).StreamOutput().Run() } func (self *BisectCommands) Mark(ref string, term string) error { cmdArgs := NewGitCmd("bisect").Arg(term, ref).ToArgv() return self.cmd.New(cmdArgs). IgnoreEmptyError(). StreamOutput(). Run() } func (self *BisectCommands) Skip(ref string) error { return self.Mark(ref, "skip") } func (self *BisectCommands) Start() error { cmdArgs := NewGitCmd("bisect").Arg("start").ToArgv() return self.cmd.New(cmdArgs).StreamOutput().Run() } func (self *BisectCommands) StartWithTerms(oldTerm string, newTerm string) error { cmdArgs := NewGitCmd("bisect").Arg("start"). Arg("--term-old=" + oldTerm). Arg("--term-new=" + newTerm). ToArgv() return self.cmd.New(cmdArgs).StreamOutput().Run() } // tells us whether we've found our problem commit(s). We return a string slice of // commit hashes if we're done, and that slice may have more that one item if // skipped commits are involved. func (self *BisectCommands) IsDone() (bool, []string, error) { info := self.GetInfo() if !info.Bisecting() { return false, nil, nil } newHash := info.GetNewHash() if newHash == "" { return false, nil, nil } // if we start from the new commit and reach the a good commit without // coming across any unprocessed commits, then we're done done := false candidates := []string{} cmdArgs := NewGitCmd("rev-list").Arg(newHash).ToArgv() err := self.cmd.New(cmdArgs).RunAndProcessLines(func(line string) (bool, error) { hash := strings.TrimSpace(line) if status, ok := info.statusMap[hash]; ok { switch status { case BisectStatusSkipped, BisectStatusNew: candidates = append(candidates, hash) return false, nil case BisectStatusOld: done = true return true, nil } } else { return true, nil } // should never land here return true, nil }) if err != nil { return false, nil, err } return done, candidates, nil } // tells us whether the 'start' ref that we'll be sent back to after we're done // bisecting is actually a descendant of our current bisect commit. If it's not, we need to // render the commits from the bad commit. func (self *BisectCommands) ReachableFromStart(bisectInfo *BisectInfo) bool { cmdArgs := NewGitCmd("merge-base"). Arg("--is-ancestor", bisectInfo.GetNewHash(), bisectInfo.GetStartHash()). ToArgv() err := self.cmd.New(cmdArgs).DontLog().Run() return err == nil } lazygit-0.50.0+ds1/pkg/commands/git_commands/bisect_info.go000066400000000000000000000047741500612110400236050ustar00rootroot00000000000000package git_commands import ( "github.com/jesseduffield/generics/maps" "github.com/samber/lo" "github.com/sirupsen/logrus" ) // although the typical terms in a git bisect are 'bad' and 'good', they're more // generally known as 'new' and 'old'. Semi-recently git allowed the user to define // their own terms e.g. when you want to used 'fixed', 'unfixed' in the event // that you're looking for a commit that fixed a bug. // Git bisect only keeps track of a single 'bad' commit. Once you pick a commit // that's older than the current bad one, it forgets about the previous one. On // the other hand, it does keep track of all the good and skipped commits. type BisectInfo struct { log *logrus.Entry // tells us whether all our git bisect files are there meaning we're in bisect mode. // Doesn't necessarily mean that we've actually picked a good/bad commit yet. started bool // this is the ref you started the commit from start string // this will always be defined // these will be defined if we've started newTerm string // 'bad' by default oldTerm string // 'good' by default // map of commit hashes to their status statusMap map[string]BisectStatus // the hash of the commit that's under test current string } type BisectStatus int const ( BisectStatusOld BisectStatus = iota BisectStatusNew BisectStatusSkipped ) // null object pattern func NewNullBisectInfo() *BisectInfo { return &BisectInfo{started: false} } func (self *BisectInfo) GetNewHash() string { for hash, status := range self.statusMap { if status == BisectStatusNew { return hash } } return "" } func (self *BisectInfo) GetCurrentHash() string { return self.current } func (self *BisectInfo) GetStartHash() string { return self.start } func (self *BisectInfo) Status(commitHash string) (BisectStatus, bool) { status, ok := self.statusMap[commitHash] return status, ok } func (self *BisectInfo) NewTerm() string { return self.newTerm } func (self *BisectInfo) OldTerm() string { return self.oldTerm } // this is for when we have called `git bisect start`. It does not // mean that we have actually started narrowing things down or selecting good/bad commits func (self *BisectInfo) Started() bool { return self.started } // this is where we have both a good and bad revision and we're actually // starting to narrow things down func (self *BisectInfo) Bisecting() bool { if !self.Started() { return false } if self.GetNewHash() == "" { return false } return lo.Contains(maps.Values(self.statusMap), BisectStatusOld) } lazygit-0.50.0+ds1/pkg/commands/git_commands/blame.go000066400000000000000000000021371500612110400223700ustar00rootroot00000000000000package git_commands import ( "fmt" ) type BlameCommands struct { *GitCommon } func NewBlameCommands(gitCommon *GitCommon) *BlameCommands { return &BlameCommands{ GitCommon: gitCommon, } } // Blame a range of lines. For each line, output the hash of the commit where // the line last changed, then a space, then a description of the commit (author // and date), another space, and then the line. For example: // // ac90ebac688fe8bc2ffd922157a9d2c54681d2aa (Stefan Haller 2023-08-01 14:54:56 +0200 11) func NewBlameCommands(gitCommon *GitCommon) *BlameCommands { // ac90ebac688fe8bc2ffd922157a9d2c54681d2aa (Stefan Haller 2023-08-01 14:54:56 +0200 12) return &BlameCommands{ // ac90ebac688fe8bc2ffd922157a9d2c54681d2aa (Stefan Haller 2023-08-01 14:54:56 +0200 13) GitCommon: gitCommon, func (self *BlameCommands) BlameLineRange(filename string, commit string, firstLine int, numLines int) (string, error) { cmdArgs := NewGitCmd("blame"). Arg("-l"). Arg(fmt.Sprintf("-L%d,+%d", firstLine, numLines)). Arg(commit). Arg("--"). Arg(filename) return self.cmd.New(cmdArgs.ToArgv()).RunWithOutput() } lazygit-0.50.0+ds1/pkg/commands/git_commands/branch.go000066400000000000000000000205511500612110400225450ustar00rootroot00000000000000package git_commands import ( "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/mgutz/str" "github.com/samber/lo" ) type BranchCommands struct { *GitCommon allBranchesLogCmdIndex uint8 // keeps track of current all branches log command } func NewBranchCommands(gitCommon *GitCommon) *BranchCommands { return &BranchCommands{ GitCommon: gitCommon, } } // New creates a new branch func (self *BranchCommands) New(name string, base string) error { cmdArgs := NewGitCmd("checkout"). Arg("-b", name, base). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *BranchCommands) NewWithoutTracking(name string, base string) error { cmdArgs := NewGitCmd("checkout"). Arg("-b", name, base). Arg("--no-track"). ToArgv() return self.cmd.New(cmdArgs).Run() } // NewWithoutCheckout creates a new branch without checking it out func (self *BranchCommands) NewWithoutCheckout(name string, base string) error { cmdArgs := NewGitCmd("branch"). Arg(name, base). ToArgv() return self.cmd.New(cmdArgs).Run() } // CreateWithUpstream creates a new branch with a given upstream, but without // checking it out func (self *BranchCommands) CreateWithUpstream(name string, upstream string) error { cmdArgs := NewGitCmd("branch"). Arg("--track"). Arg(name, upstream). ToArgv() return self.cmd.New(cmdArgs).Run() } // CurrentBranchInfo get the current branch information. func (self *BranchCommands) CurrentBranchInfo() (BranchInfo, error) { branchName, err := self.cmd.New( NewGitCmd("symbolic-ref"). Arg("--short", "HEAD"). ToArgv(), ).DontLog().RunWithOutput() if err == nil && branchName != "HEAD\n" { trimmedBranchName := strings.TrimSpace(branchName) return BranchInfo{ RefName: trimmedBranchName, DisplayName: trimmedBranchName, DetachedHead: false, }, nil } output, err := self.cmd.New( NewGitCmd("branch"). Arg("--points-at=HEAD", "--format=%(HEAD)%00%(objectname)%00%(refname)"). ToArgv(), ).DontLog().RunWithOutput() if err != nil { return BranchInfo{}, err } for _, line := range utils.SplitLines(output) { split := strings.Split(strings.TrimRight(line, "\r\n"), "\x00") if len(split) == 3 && split[0] == "*" { hash := split[1] displayName := split[2] return BranchInfo{ RefName: hash, DisplayName: displayName, DetachedHead: true, }, nil } } return BranchInfo{ RefName: "HEAD", DisplayName: "HEAD", DetachedHead: true, }, nil } // CurrentBranchName get name of current branch func (self *BranchCommands) CurrentBranchName() (string, error) { cmdArgs := NewGitCmd("rev-parse"). Arg("--abbrev-ref"). Arg("--verify"). Arg("HEAD"). ToArgv() output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() if err == nil { return strings.TrimSpace(output), nil } return "", err } // LocalDelete delete branch locally func (self *BranchCommands) LocalDelete(branches []string, force bool) error { cmdArgs := NewGitCmd("branch"). ArgIfElse(force, "-D", "-d"). Arg(branches...). ToArgv() return self.cmd.New(cmdArgs).Run() } // Checkout checks out a branch (or commit), with --force if you set the force arg to true type CheckoutOptions struct { Force bool EnvVars []string } func (self *BranchCommands) Checkout(branch string, options CheckoutOptions) error { cmdArgs := NewGitCmd("checkout"). ArgIf(options.Force, "--force"). Arg(branch). ToArgv() return self.cmd.New(cmdArgs). // prevents git from prompting us for input which would freeze the program // TODO: see if this is actually needed here AddEnvVars("GIT_TERMINAL_PROMPT=0"). AddEnvVars(options.EnvVars...). Run() } // GetGraph gets the color-formatted graph of the log for the given branch // Currently it limits the result to 100 commits, but when we get async stuff // working we can do lazy loading func (self *BranchCommands) GetGraph(branchName string) (string, error) { return self.GetGraphCmdObj(branchName).DontLog().RunWithOutput() } func (self *BranchCommands) GetGraphCmdObj(branchName string) oscommands.ICmdObj { branchLogCmdTemplate := self.UserConfig().Git.BranchLogCmd templateValues := map[string]string{ "branchName": self.cmd.Quote(branchName), } resolvedTemplate := utils.ResolvePlaceholderString(branchLogCmdTemplate, templateValues) return self.cmd.New(str.ToArgv(resolvedTemplate)).DontLog() } func (self *BranchCommands) SetCurrentBranchUpstream(remoteName string, remoteBranchName string) error { cmdArgs := NewGitCmd("branch"). Arg(fmt.Sprintf("--set-upstream-to=%s/%s", remoteName, remoteBranchName)). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *BranchCommands) SetUpstream(remoteName string, remoteBranchName string, branchName string) error { cmdArgs := NewGitCmd("branch"). Arg(fmt.Sprintf("--set-upstream-to=%s/%s", remoteName, remoteBranchName)). Arg(branchName). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *BranchCommands) UnsetUpstream(branchName string) error { cmdArgs := NewGitCmd("branch").Arg("--unset-upstream", branchName). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *BranchCommands) GetCurrentBranchUpstreamDifferenceCount() (string, string) { return self.GetCommitDifferences("HEAD", "HEAD@{u}") } func (self *BranchCommands) GetUpstreamDifferenceCount(branchName string) (string, string) { return self.GetCommitDifferences(branchName, branchName+"@{u}") } // GetCommitDifferences checks how many pushables/pullables there are for the // current branch func (self *BranchCommands) GetCommitDifferences(from, to string) (string, string) { pushableCount, err := self.countDifferences(to, from) if err != nil { return "?", "?" } pullableCount, err := self.countDifferences(from, to) if err != nil { return "?", "?" } return strings.TrimSpace(pushableCount), strings.TrimSpace(pullableCount) } func (self *BranchCommands) countDifferences(from, to string) (string, error) { cmdArgs := NewGitCmd("rev-list"). Arg(fmt.Sprintf("%s..%s", from, to)). Arg("--count"). ToArgv() return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } func (self *BranchCommands) IsHeadDetached() bool { cmdArgs := NewGitCmd("symbolic-ref").Arg("-q", "HEAD").ToArgv() err := self.cmd.New(cmdArgs).DontLog().Run() return err != nil } func (self *BranchCommands) Rename(oldName string, newName string) error { cmdArgs := NewGitCmd("branch"). Arg("--move", oldName, newName). ToArgv() return self.cmd.New(cmdArgs).Run() } type MergeOpts struct { FastForwardOnly bool Squash bool } func (self *BranchCommands) Merge(branchName string, opts MergeOpts) error { if opts.Squash && opts.FastForwardOnly { panic("Squash and FastForwardOnly can't both be true") } cmdArgs := NewGitCmd("merge"). Arg("--no-edit"). Arg(strings.Fields(self.UserConfig().Git.Merging.Args)...). ArgIf(opts.FastForwardOnly, "--ff-only"). ArgIf(opts.Squash, "--squash", "--ff"). Arg(branchName). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *BranchCommands) AllBranchesLogCmdObj() oscommands.ICmdObj { // Only choose between non-empty, non-identical commands candidates := lo.Uniq(lo.WithoutEmpty(append([]string{ self.UserConfig().Git.AllBranchesLogCmd, }, self.UserConfig().Git.AllBranchesLogCmds..., ))) n := len(candidates) i := self.allBranchesLogCmdIndex self.allBranchesLogCmdIndex = uint8((int(i) + 1) % n) return self.cmd.New(str.ToArgv(candidates[i])).DontLog() } func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *MainBranches) (bool, error) { branchesToCheckAgainst := []string{"HEAD"} if branch.RemoteBranchStoredLocally() { branchesToCheckAgainst = append(branchesToCheckAgainst, fmt.Sprintf("%s@{upstream}", branch.Name)) } branchesToCheckAgainst = append(branchesToCheckAgainst, mainBranches.Get()...) cmdArgs := NewGitCmd("rev-list"). Arg("--max-count=1"). Arg(branch.Name). Arg(lo.Map(branchesToCheckAgainst, func(branch string, _ int) string { return fmt.Sprintf("^%s", branch) })...). Arg("--"). ToArgv() stdout, _, err := self.cmd.New(cmdArgs).RunWithOutputs() if err != nil { return false, err } return stdout == "", nil } func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error { cmdArgs := NewGitCmd("update-ref"). Arg("--stdin"). ToArgv() return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run() } lazygit-0.50.0+ds1/pkg/commands/git_commands/branch_loader.go000066400000000000000000000254671500612110400241060ustar00rootroot00000000000000package git_commands import ( "fmt" "regexp" "slices" "strconv" "strings" "time" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/go-git/v5/config" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "golang.org/x/sync/errgroup" ) // context: // we want to only show 'safe' branches (ones that haven't e.g. been deleted) // which `git branch -a` gives us, but we also want the recency data that // git reflog gives us. // So we get the HEAD, then append get the reflog branches that intersect with // our safe branches, then add the remaining safe branches, ensuring uniqueness // along the way // if we find out we need to use one of these functions in the git.go file, we // can just pull them out of here and put them there and then call them from in here type BranchLoaderConfigCommands interface { Branches() (map[string]*config.Branch, error) } type BranchInfo struct { RefName string DisplayName string // e.g. '(HEAD detached at 123asdf)' DetachedHead bool } // BranchLoader returns a list of Branch objects for the current repo type BranchLoader struct { *common.Common *GitCommon cmd oscommands.ICmdObjBuilder getCurrentBranchInfo func() (BranchInfo, error) config BranchLoaderConfigCommands } func NewBranchLoader( cmn *common.Common, gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder, getCurrentBranchInfo func() (BranchInfo, error), config BranchLoaderConfigCommands, ) *BranchLoader { return &BranchLoader{ Common: cmn, GitCommon: gitCommon, cmd: cmd, getCurrentBranchInfo: getCurrentBranchInfo, config: config, } } // Load the list of branches for the current repo func (self *BranchLoader) Load(reflogCommits []*models.Commit, mainBranches *MainBranches, oldBranches []*models.Branch, loadBehindCounts bool, onWorker func(func() error), renderFunc func(), ) ([]*models.Branch, error) { branches := self.obtainBranches() if self.AppState.LocalBranchSortOrder == "recency" { reflogBranches := self.obtainReflogBranches(reflogCommits) // loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches branchesWithRecency := make([]*models.Branch, 0) outer: for _, reflogBranch := range reflogBranches { for j, branch := range branches { if branch.Head { continue } if strings.EqualFold(reflogBranch.Name, branch.Name) { branch.Recency = reflogBranch.Recency branchesWithRecency = append(branchesWithRecency, branch) branches = utils.Remove(branches, j) continue outer } } } // Sort branches that don't have a recency value alphabetically // (we're really doing this for the sake of deterministic behaviour across git versions) slices.SortFunc(branches, func(a *models.Branch, b *models.Branch) int { return strings.Compare(a.Name, b.Name) }) branches = utils.Prepend(branches, branchesWithRecency...) } foundHead := false for i, branch := range branches { if branch.Head { foundHead = true branch.Recency = " *" branches = utils.Move(branches, i, 0) break } } if !foundHead { info, err := self.getCurrentBranchInfo() if err != nil { return nil, err } branches = utils.Prepend(branches, &models.Branch{Name: info.RefName, DisplayName: info.DisplayName, Head: true, DetachedHead: info.DetachedHead, Recency: " *"}) } configBranches, err := self.config.Branches() if err != nil { return nil, err } for _, branch := range branches { match := configBranches[branch.Name] if match != nil { branch.UpstreamRemote = match.Remote branch.UpstreamBranch = match.Merge.Short() } // If the branch already existed, take over its BehindBaseBranch value // to reduce flicker if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool { return b.Name == branch.Name }); found { branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load()) } } if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" { onWorker(func() error { return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc) }) } return branches, nil } func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches( branches []*models.Branch, mainBranches *MainBranches, renderFunc func(), ) error { mainBranchRefs := mainBranches.Get() if len(mainBranchRefs) == 0 { return nil } t := time.Now() errg := errgroup.Group{} for _, branch := range branches { errg.Go(func() error { baseBranch, err := self.GetBaseBranch(branch, mainBranches) if err != nil { return err } behind := 0 // prime it in case something below fails if baseBranch != "" { output, err := self.cmd.New( NewGitCmd("rev-list"). Arg("--left-right"). Arg("--count"). Arg(fmt.Sprintf("%s...%s", branch.FullRefName(), baseBranch)). ToArgv(), ).DontLog().RunWithOutput() if err != nil { return err } // The format of the output is "\t" aheadBehindStr := strings.Split(strings.TrimSpace(output), "\t") if len(aheadBehindStr) == 2 { if value, err := strconv.Atoi(aheadBehindStr[1]); err == nil { behind = value } } } branch.BehindBaseBranch.Store(int32(behind)) return nil }) } err := errg.Wait() self.Log.Debugf("time to get behind base branch values for all branches: %s", time.Since(t)) renderFunc() return err } // Find the base branch for the given branch (i.e. the main branch that the // given branch was forked off of) // // Note that this function may return an empty string even if the returned error // is nil, e.g. when none of the configured main branches exist. This is not // considered an error condition, so callers need to check both the returned // error and whether the returned base branch is empty (and possibly react // differently in both cases). func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *MainBranches) (string, error) { mergeBase := mainBranches.GetMergeBase(branch.FullRefName()) if mergeBase == "" { return "", nil } output, err := self.cmd.New( NewGitCmd("for-each-ref"). Arg("--contains"). Arg(mergeBase). Arg("--format=%(refname)"). Arg(mainBranches.Get()...). ToArgv(), ).DontLog().RunWithOutput() if err != nil { return "", err } trimmedOutput := strings.TrimSpace(output) split := strings.Split(trimmedOutput, "\n") if len(split) == 0 || split[0] == "" { return "", nil } return split[0], nil } func (self *BranchLoader) obtainBranches() []*models.Branch { output, err := self.getRawBranches() if err != nil { panic(err) } trimmedOutput := strings.TrimSpace(output) outputLines := strings.Split(trimmedOutput, "\n") return lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) { if line == "" { return nil, false } split := strings.Split(line, "\x00") if len(split) != len(branchFields) { // Ignore line if it isn't separated into the expected number of parts // This is probably a warning message, for more info see: // https://github.com/jesseduffield/lazygit/issues/1385#issuecomment-885580439 return nil, false } storeCommitDateAsRecency := self.AppState.LocalBranchSortOrder != "recency" return obtainBranch(split, storeCommitDateAsRecency), true }) } func (self *BranchLoader) getRawBranches() (string, error) { format := strings.Join( lo.Map(branchFields, func(thing string, _ int) string { return "%(" + thing + ")" }), "%00", ) var sortOrder string switch strings.ToLower(self.AppState.LocalBranchSortOrder) { case "recency", "date": sortOrder = "-committerdate" case "alphabetical": sortOrder = "refname" default: sortOrder = "refname" } cmdArgs := NewGitCmd("for-each-ref"). Arg(fmt.Sprintf("--sort=%s", sortOrder)). Arg(fmt.Sprintf("--format=%s", format)). Arg("refs/heads"). ToArgv() return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } var branchFields = []string{ "HEAD", "refname:short", "upstream:short", "upstream:track", "push:track", "subject", "objectname", "committerdate:unix", } // Obtain branch information from parsed line output of getRawBranches() func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch { headMarker := split[0] fullName := split[1] upstreamName := split[2] track := split[3] pushTrack := split[4] subject := split[5] commitHash := split[6] commitDate := split[7] name := strings.TrimPrefix(fullName, "heads/") aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track) aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack) recency := "" if storeCommitDateAsRecency { if unixTimestamp, err := strconv.ParseInt(commitDate, 10, 64); err == nil { recency = utils.UnixToTimeAgo(unixTimestamp) } } return &models.Branch{ Name: name, Recency: recency, AheadForPull: aheadForPull, BehindForPull: behindForPull, AheadForPush: aheadForPush, BehindForPush: behindForPush, UpstreamGone: gone, Head: headMarker == "*", Subject: subject, CommitHash: commitHash, } } func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) { if upstreamName == "" { // if we're here then it means we do not have a local version of the remote. // The branch might still be tracking a remote though, we just don't know // how many commits ahead/behind it is return "?", "?", false } if track == "[gone]" { return "?", "?", true } ahead := parseDifference(track, `ahead (\d+)`) behind := parseDifference(track, `behind (\d+)`) return ahead, behind, false } func parseDifference(track string, regexStr string) string { re := regexp.MustCompile(regexStr) match := re.FindStringSubmatch(track) if len(match) > 1 { return match[1] } else { return "0" } } // TODO: only look at the new reflog commits, and otherwise store the recencies in // int form against the branch to recalculate the time ago func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch { foundBranches := set.New[string]() re := regexp.MustCompile(`checkout: moving from ([\S]+) to ([\S]+)`) reflogBranches := make([]*models.Branch, 0, len(reflogCommits)) for _, commit := range reflogCommits { match := re.FindStringSubmatch(commit.Name) if len(match) != 3 { continue } recency := utils.UnixToTimeAgo(commit.UnixTimestamp) for _, branchName := range match[1:] { if !foundBranches.Includes(branchName) { foundBranches.Add(branchName) reflogBranches = append(reflogBranches, &models.Branch{ Recency: recency, Name: branchName, }) } } } return reflogBranches } lazygit-0.50.0+ds1/pkg/commands/git_commands/branch_loader_test.go000066400000000000000000000067671500612110400251470ustar00rootroot00000000000000package git_commands // "*|feat/detect-purge|origin/feat/detect-purge|[ahead 1]" import ( "strconv" "testing" "time" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/stretchr/testify/assert" ) func TestObtainBranch(t *testing.T) { type scenario struct { testName string input []string storeCommitDateAsRecency bool expectedBranch *models.Branch } // Use a time stamp of 2 1/2 hours ago, resulting in a recency string of "2h" now := time.Now().Unix() timeStamp := strconv.Itoa(int(now - 2.5*60*60)) scenarios := []scenario{ { testName: "TrimHeads", input: []string{"", "heads/a_branch", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", AheadForPull: "?", BehindForPull: "?", AheadForPush: "?", BehindForPush: "?", Head: false, Subject: "subject", CommitHash: "123", }, }, { testName: "NoUpstream", input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", AheadForPull: "?", BehindForPull: "?", AheadForPush: "?", BehindForPush: "?", Head: false, Subject: "subject", CommitHash: "123", }, }, { testName: "IsHead", input: []string{"*", "a_branch", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", AheadForPull: "?", BehindForPull: "?", AheadForPush: "?", BehindForPush: "?", Head: true, Subject: "subject", CommitHash: "123", }, }, { testName: "IsBehindAndAhead", input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", AheadForPull: "3", BehindForPull: "2", AheadForPush: "3", BehindForPush: "2", Head: false, Subject: "subject", CommitHash: "123", }, }, { testName: "RemoteBranchIsGone", input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", UpstreamGone: true, AheadForPull: "?", BehindForPull: "?", AheadForPush: "?", BehindForPush: "?", Head: false, Subject: "subject", CommitHash: "123", }, }, { testName: "WithCommitDateAsRecency", input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: true, expectedBranch: &models.Branch{ Name: "a_branch", Recency: "2h", AheadForPull: "?", BehindForPull: "?", AheadForPush: "?", BehindForPush: "?", Head: false, Subject: "subject", CommitHash: "123", }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { branch := obtainBranch(s.input, s.storeCommitDateAsRecency) assert.EqualValues(t, s.expectedBranch, branch) }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/branch_test.go000066400000000000000000000217671500612110400236160ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/stretchr/testify/assert" ) func TestBranchGetCommitDifferences(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner expectedPushables string expectedPullables string } scenarios := []scenario{ { "Can't retrieve pushable count", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rev-list", "@{u}..HEAD", "--count"}, "", errors.New("error")), "?", "?", }, { "Can't retrieve pullable count", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rev-list", "@{u}..HEAD", "--count"}, "1\n", nil). ExpectGitArgs([]string{"rev-list", "HEAD..@{u}", "--count"}, "", errors.New("error")), "?", "?", }, { "Retrieve pullable and pushable count", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rev-list", "@{u}..HEAD", "--count"}, "1\n", nil). ExpectGitArgs([]string{"rev-list", "HEAD..@{u}", "--count"}, "2\n", nil), "1", "2", }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildBranchCommands(commonDeps{runner: s.runner}) pushables, pullables := instance.GetCommitDifferences("HEAD", "@{u}") assert.EqualValues(t, s.expectedPushables, pushables) assert.EqualValues(t, s.expectedPullables, pullables) s.runner.CheckForMissingCalls() }) } } func TestBranchNewBranch(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "-b", "test", "refs/heads/master"}, "", nil) instance := buildBranchCommands(commonDeps{runner: runner}) assert.NoError(t, instance.New("test", "refs/heads/master")) runner.CheckForMissingCalls() } func TestBranchDeleteBranch(t *testing.T) { type scenario struct { testName string branchNames []string force bool runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { "Delete a branch", []string{"test"}, false, oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-d", "test"}, "", nil), func(err error) { assert.NoError(t, err) }, }, { "Delete multiple branches", []string{"test1", "test2", "test3"}, false, oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-d", "test1", "test2", "test3"}, "", nil), func(err error) { assert.NoError(t, err) }, }, { "Force delete a branch", []string{"test"}, true, oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-D", "test"}, "", nil), func(err error) { assert.NoError(t, err) }, }, { "Force delete multiple branches", []string{"test1", "test2", "test3"}, true, oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-D", "test1", "test2", "test3"}, "", nil), func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildBranchCommands(commonDeps{runner: s.runner}) s.test(instance.LocalDelete(s.branchNames, s.force)) s.runner.CheckForMissingCalls() }) } } func TestBranchMerge(t *testing.T) { scenarios := []struct { testName string userConfig *config.UserConfig opts MergeOpts branchName string expected []string }{ { testName: "basic", userConfig: &config.UserConfig{}, opts: MergeOpts{}, branchName: "mybranch", expected: []string{"merge", "--no-edit", "mybranch"}, }, { testName: "merging args", userConfig: &config.UserConfig{ Git: config.GitConfig{ Merging: config.MergingConfig{ Args: "--merging-args", // it's up to the user what they put here }, }, }, opts: MergeOpts{}, branchName: "mybranch", expected: []string{"merge", "--no-edit", "--merging-args", "mybranch"}, }, { testName: "multiple merging args", userConfig: &config.UserConfig{ Git: config.GitConfig{ Merging: config.MergingConfig{ Args: "--arg1 --arg2", // it's up to the user what they put here }, }, }, opts: MergeOpts{}, branchName: "mybranch", expected: []string{"merge", "--no-edit", "--arg1", "--arg2", "mybranch"}, }, { testName: "fast forward only", userConfig: &config.UserConfig{}, opts: MergeOpts{FastForwardOnly: true}, branchName: "mybranch", expected: []string{"merge", "--no-edit", "--ff-only", "mybranch"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs(s.expected, "", nil) instance := buildBranchCommands(commonDeps{runner: runner, userConfig: s.userConfig}) assert.NoError(t, instance.Merge(s.branchName, s.opts)) runner.CheckForMissingCalls() }) } } func TestBranchCheckout(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner test func(error) force bool } scenarios := []scenario{ { "Checkout", oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"checkout", "test"}, "", nil), func(err error) { assert.NoError(t, err) }, false, }, { "Checkout forced", oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"checkout", "--force", "test"}, "", nil), func(err error) { assert.NoError(t, err) }, true, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildBranchCommands(commonDeps{runner: s.runner}) s.test(instance.Checkout("test", CheckoutOptions{Force: s.force})) s.runner.CheckForMissingCalls() }) } } func TestBranchGetBranchGraph(t *testing.T) { runner := oscommands.NewFakeRunner(t).ExpectGitArgs([]string{ "log", "--graph", "--color=always", "--abbrev-commit", "--decorate", "--date=relative", "--pretty=medium", "test", "--", }, "", nil) instance := buildBranchCommands(commonDeps{runner: runner}) _, err := instance.GetGraph("test") assert.NoError(t, err) } func TestBranchGetAllBranchGraph(t *testing.T) { runner := oscommands.NewFakeRunner(t).ExpectGitArgs([]string{ "log", "--graph", "--all", "--color=always", "--abbrev-commit", "--decorate", "--date=relative", "--pretty=medium", }, "", nil) instance := buildBranchCommands(commonDeps{runner: runner}) err := instance.AllBranchesLogCmdObj().Run() assert.NoError(t, err) } func TestBranchCurrentBranchInfo(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner test func(BranchInfo, error) } scenarios := []scenario{ { "says we are on the master branch if we are", oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"symbolic-ref", "--short", "HEAD"}, "master", nil), func(info BranchInfo, err error) { assert.NoError(t, err) assert.EqualValues(t, "master", info.RefName) assert.EqualValues(t, "master", info.DisplayName) assert.False(t, info.DetachedHead) }, }, { "falls back to git `git branch --points-at=HEAD` if symbolic-ref fails", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"symbolic-ref", "--short", "HEAD"}, "", errors.New("error")). ExpectGitArgs([]string{"branch", "--points-at=HEAD", "--format=%(HEAD)%00%(objectname)%00%(refname)"}, "*\x006f71c57a8d4bd6c11399c3f55f42c815527a73a4\x00(HEAD detached at 6f71c57a)\n", nil), func(info BranchInfo, err error) { assert.NoError(t, err) assert.EqualValues(t, "6f71c57a8d4bd6c11399c3f55f42c815527a73a4", info.RefName) assert.EqualValues(t, "(HEAD detached at 6f71c57a)", info.DisplayName) assert.True(t, info.DetachedHead) }, }, { "handles a detached head (LANG=zh_CN.UTF-8)", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"symbolic-ref", "--short", "HEAD"}, "", errors.New("error")). ExpectGitArgs( []string{"branch", "--points-at=HEAD", "--format=%(HEAD)%00%(objectname)%00%(refname)"}, "*\x00679b0456f3db7c505b398def84e7d023e5b55a8d\x00(头指针在 679b0456 分离)\n"+ " \x00679b0456f3db7c505b398def84e7d023e5b55a8d\x00refs/heads/master\n", nil), func(info BranchInfo, err error) { assert.NoError(t, err) assert.EqualValues(t, "679b0456f3db7c505b398def84e7d023e5b55a8d", info.RefName) assert.EqualValues(t, "(头指针在 679b0456 分离)", info.DisplayName) assert.True(t, info.DetachedHead) }, }, { "bubbles up error if there is one", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"symbolic-ref", "--short", "HEAD"}, "", errors.New("error")). ExpectGitArgs([]string{"branch", "--points-at=HEAD", "--format=%(HEAD)%00%(objectname)%00%(refname)"}, "", errors.New("error")), func(info BranchInfo, err error) { assert.Error(t, err) assert.EqualValues(t, "", info.RefName) assert.EqualValues(t, "", info.DisplayName) assert.False(t, info.DetachedHead) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildBranchCommands(commonDeps{runner: s.runner}) s.test(instance.CurrentBranchInfo()) s.runner.CheckForMissingCalls() }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/commit.go000066400000000000000000000244411500612110400226020ustar00rootroot00000000000000package git_commands import ( "fmt" "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) var ErrInvalidCommitIndex = errors.New("invalid commit index") type CommitCommands struct { *GitCommon } func NewCommitCommands(gitCommon *GitCommon) *CommitCommands { return &CommitCommands{ GitCommon: gitCommon, } } // ResetAuthor resets the author of the topmost commit func (self *CommitCommands) ResetAuthor() error { cmdArgs := NewGitCmd("commit"). Arg("--allow-empty", "--only", "--no-edit", "--amend", "--reset-author"). ToArgv() return self.cmd.New(cmdArgs).Run() } // Sets the commit's author to the supplied value. Value is expected to be of the form 'Name ' func (self *CommitCommands) SetAuthor(value string) error { cmdArgs := NewGitCmd("commit"). Arg("--allow-empty", "--only", "--no-edit", "--amend", "--author="+value). ToArgv() return self.cmd.New(cmdArgs).Run() } // Add a commit's coauthor using Github/Gitlab Co-authored-by metadata. Value is expected to be of the form 'Name ' func (self *CommitCommands) AddCoAuthor(hash string, author string) error { message, err := self.GetCommitMessage(hash) if err != nil { return err } message = AddCoAuthorToMessage(message, author) cmdArgs := NewGitCmd("commit"). Arg("--allow-empty", "--amend", "--only", "-m", message). ToArgv() return self.cmd.New(cmdArgs).Run() } func AddCoAuthorToMessage(message string, author string) string { subject, body, _ := strings.Cut(message, "\n") return strings.TrimSpace(subject) + "\n\n" + AddCoAuthorToDescription(strings.TrimSpace(body), author) } func AddCoAuthorToDescription(description string, author string) string { if description != "" { lines := strings.Split(description, "\n") if strings.HasPrefix(lines[len(lines)-1], "Co-authored-by:") { description += "\n" } else { description += "\n\n" } } return description + fmt.Sprintf("Co-authored-by: %s", author) } // ResetToCommit reset to commit func (self *CommitCommands) ResetToCommit(hash string, strength string, envVars []string) error { cmdArgs := NewGitCmd("reset").Arg("--"+strength, hash).ToArgv() return self.cmd.New(cmdArgs). // prevents git from prompting us for input which would freeze the program // TODO: see if this is actually needed here AddEnvVars("GIT_TERMINAL_PROMPT=0"). AddEnvVars(envVars...). Run() } func (self *CommitCommands) CommitCmdObj(summary string, description string, forceSkipHooks bool) oscommands.ICmdObj { messageArgs := self.commitMessageArgs(summary, description) skipHookPrefix := self.UserConfig().Git.SkipHookPrefix cmdArgs := NewGitCmd("commit"). ArgIf(forceSkipHooks || (skipHookPrefix != "" && strings.HasPrefix(summary, skipHookPrefix)), "--no-verify"). ArgIf(self.signoffFlag() != "", self.signoffFlag()). Arg(messageArgs...). ToArgv() return self.cmd.New(cmdArgs) } func (self *CommitCommands) RewordLastCommitInEditorCmdObj() oscommands.ICmdObj { return self.cmd.New(NewGitCmd("commit").Arg("--allow-empty", "--amend", "--only").ToArgv()) } func (self *CommitCommands) RewordLastCommitInEditorWithMessageFileCmdObj(tmpMessageFile string) oscommands.ICmdObj { return self.cmd.New(NewGitCmd("commit"). Arg("--allow-empty", "--amend", "--only", "--edit", "--file="+tmpMessageFile).ToArgv()) } func (self *CommitCommands) CommitInEditorWithMessageFileCmdObj(tmpMessageFile string, forceSkipHooks bool) oscommands.ICmdObj { return self.cmd.New(NewGitCmd("commit"). ArgIf(forceSkipHooks, "--no-verify"). Arg("--edit"). Arg("--file="+tmpMessageFile). ArgIf(self.signoffFlag() != "", self.signoffFlag()). ToArgv()) } // RewordLastCommit rewords the topmost commit with the given message func (self *CommitCommands) RewordLastCommit(summary string, description string) oscommands.ICmdObj { messageArgs := self.commitMessageArgs(summary, description) cmdArgs := NewGitCmd("commit"). Arg("--allow-empty", "--amend", "--only"). Arg(messageArgs...). ToArgv() return self.cmd.New(cmdArgs) } func (self *CommitCommands) commitMessageArgs(summary string, description string) []string { args := []string{"-m", summary} if description != "" { args = append(args, "-m", description) } return args } // runs git commit without the -m argument meaning it will invoke the user's editor func (self *CommitCommands) CommitEditorCmdObj() oscommands.ICmdObj { cmdArgs := NewGitCmd("commit"). ArgIf(self.signoffFlag() != "", self.signoffFlag()). ToArgv() return self.cmd.New(cmdArgs) } func (self *CommitCommands) signoffFlag() string { if self.UserConfig().Git.Commit.SignOff { return "--signoff" } else { return "" } } func (self *CommitCommands) GetCommitMessage(commitHash string) (string, error) { cmdArgs := NewGitCmd("log"). Arg("--format=%B", "--max-count=1", commitHash). Config("log.showsignature=false"). ToArgv() message, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() return strings.ReplaceAll(strings.TrimSpace(message), "\r\n", "\n"), err } func (self *CommitCommands) GetCommitSubject(commitHash string) (string, error) { cmdArgs := NewGitCmd("log"). Arg("--format=%s", "--max-count=1", commitHash). Config("log.showsignature=false"). ToArgv() subject, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() return strings.TrimSpace(subject), err } func (self *CommitCommands) GetCommitDiff(commitHash string) (string, error) { cmdArgs := NewGitCmd("show").Arg("--no-color", commitHash).ToArgv() diff, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() return diff, err } type Author struct { Name string Email string } func (self *CommitCommands) GetCommitAuthor(commitHash string) (Author, error) { cmdArgs := NewGitCmd("show"). Arg("--no-patch", "--pretty=format:%an%x00%ae", commitHash). ToArgv() output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() if err != nil { return Author{}, err } split := strings.SplitN(strings.TrimSpace(output), "\x00", 2) if len(split) < 2 { return Author{}, errors.New("unexpected git output") } author := Author{Name: split[0], Email: split[1]} return author, err } func (self *CommitCommands) GetCommitMessageFirstLine(hash string) (string, error) { return self.GetCommitMessagesFirstLine([]string{hash}) } func (self *CommitCommands) GetCommitMessagesFirstLine(hashes []string) (string, error) { cmdArgs := NewGitCmd("show"). Arg("--no-patch", "--pretty=format:%s"). Arg(hashes...). ToArgv() return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } // Example output: // // cd50c79ae Preserve the commit message correctly even if the description has blank lines // 3ebba5f32 Add test demonstrating a bug with preserving the commit message // 9a423c388 Remove unused function func (self *CommitCommands) GetHashesAndCommitMessagesFirstLine(hashes []string) (string, error) { cmdArgs := NewGitCmd("show"). Arg("--no-patch", "--pretty=format:%h %s"). Arg(hashes...). ToArgv() return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } func (self *CommitCommands) GetCommitsOneline(hashes []string) (string, error) { cmdArgs := NewGitCmd("show"). Arg("--no-patch", "--oneline"). Arg(hashes...). ToArgv() return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } // AmendHead amends HEAD with whatever is staged in your working tree func (self *CommitCommands) AmendHead() error { return self.AmendHeadCmdObj().Run() } func (self *CommitCommands) AmendHeadCmdObj() oscommands.ICmdObj { cmdArgs := NewGitCmd("commit"). Arg("--amend", "--no-edit", "--allow-empty"). ToArgv() return self.cmd.New(cmdArgs) } func (self *CommitCommands) ShowCmdObj(hash string, filterPath string) oscommands.ICmdObj { contextSize := self.AppState.DiffContextSize extDiffCmd := self.UserConfig().Git.Paging.ExternalDiffCommand cmdArgs := NewGitCmd("show"). Config("diff.noprefix=false"). ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd). ArgIfElse(extDiffCmd != "", "--ext-diff", "--no-ext-diff"). Arg("--submodule"). Arg("--color="+self.UserConfig().Git.Paging.ColorArg). Arg(fmt.Sprintf("--unified=%d", contextSize)). Arg("--stat"). Arg("--decorate"). Arg("-p"). Arg(hash). ArgIf(self.AppState.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg(fmt.Sprintf("--find-renames=%d%%", self.AppState.RenameSimilarityThreshold)). ArgIf(filterPath != "", "--", filterPath). Dir(self.repoPaths.worktreePath). ToArgv() return self.cmd.New(cmdArgs).DontLog() } func (self *CommitCommands) ShowFileContentCmdObj(hash string, filePath string) oscommands.ICmdObj { cmdArgs := NewGitCmd("show"). Arg(fmt.Sprintf("%s:%s", hash, filePath)). ToArgv() return self.cmd.New(cmdArgs).DontLog() } // Revert reverts the selected commits by hash. If isMerge is true, we'll pass -m 1 // to say we want to revert the first parent of the merge commit, which is the one // people want in 99.9% of cases. In current git versions we could unconditionally // pass -m 1 even for non-merge commits, but older versions of git choke on it. func (self *CommitCommands) Revert(hashes []string, isMerge bool) error { cmdArgs := NewGitCmd("revert"). ArgIf(isMerge, "-m", "1"). Arg(hashes...). ToArgv() return self.cmd.New(cmdArgs).Run() } // CreateFixupCommit creates a commit that fixes up a previous commit func (self *CommitCommands) CreateFixupCommit(hash string) error { cmdArgs := NewGitCmd("commit").Arg("--fixup=" + hash).ToArgv() return self.cmd.New(cmdArgs).Run() } // CreateAmendCommit creates a commit that changes the commit message of a previous commit func (self *CommitCommands) CreateAmendCommit(originalSubject, newSubject, newDescription string, includeFileChanges bool) error { description := newSubject if newDescription != "" { description += "\n\n" + newDescription } cmdArgs := NewGitCmd("commit"). Arg("-m", "amend! "+originalSubject). Arg("-m", description). ArgIf(!includeFileChanges, "--only", "--allow-empty"). ToArgv() return self.cmd.New(cmdArgs).Run() } // a value of 0 means the head commit, 1 is the parent commit, etc func (self *CommitCommands) GetCommitMessageFromHistory(value int) (string, error) { cmdArgs := NewGitCmd("log").Arg("-1", fmt.Sprintf("--skip=%d", value), "--pretty=%H"). ToArgv() hash, _ := self.cmd.New(cmdArgs).DontLog().RunWithOutput() formattedHash := strings.TrimSpace(hash) if len(formattedHash) == 0 { return "", ErrInvalidCommitIndex } return self.GetCommitMessage(formattedHash) } lazygit-0.50.0+ds1/pkg/commands/git_commands/commit_file_loader.go000066400000000000000000000032031500612110400251200ustar00rootroot00000000000000package git_commands import ( "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/samber/lo" ) type CommitFileLoader struct { *common.Common cmd oscommands.ICmdObjBuilder } func NewCommitFileLoader(common *common.Common, cmd oscommands.ICmdObjBuilder) *CommitFileLoader { return &CommitFileLoader{ Common: common, cmd: cmd, } } // GetFilesInDiff get the specified commit files func (self *CommitFileLoader) GetFilesInDiff(from string, to string, reverse bool) ([]*models.CommitFile, error) { cmdArgs := NewGitCmd("diff"). Config("diff.noprefix=false"). Arg("--submodule"). Arg("--no-ext-diff"). Arg("--name-status"). Arg("-z"). Arg("--no-renames"). ArgIf(reverse, "-R"). Arg(from). Arg(to). ToArgv() filenames, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() if err != nil { return nil, err } return getCommitFilesFromFilenames(filenames), nil } // filenames string is something like "MM\x00file1\x00MU\x00file2\x00AA\x00file3\x00" // so we need to split it by the null character and then map each status-name pair to a commit file func getCommitFilesFromFilenames(filenames string) []*models.CommitFile { lines := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00") if len(lines) == 1 { return []*models.CommitFile{} } // typical result looks like 'A my_file' meaning my_file was added return lo.Map(lo.Chunk(lines, 2), func(chunk []string, _ int) *models.CommitFile { return &models.CommitFile{ ChangeStatus: chunk[0], Path: chunk[1], } }) } lazygit-0.50.0+ds1/pkg/commands/git_commands/commit_file_loader_test.go000066400000000000000000000025131500612110400261620ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/stretchr/testify/assert" ) func TestGetCommitFilesFromFilenames(t *testing.T) { tests := []struct { testName string input string output []*models.CommitFile }{ { testName: "no files", input: "", output: []*models.CommitFile{}, }, { testName: "one file", input: "MM\x00Myfile\x00", output: []*models.CommitFile{ { Path: "Myfile", ChangeStatus: "MM", }, }, }, { testName: "two files", input: "MM\x00Myfile\x00M \x00MyOtherFile\x00", output: []*models.CommitFile{ { Path: "Myfile", ChangeStatus: "MM", }, { Path: "MyOtherFile", ChangeStatus: "M ", }, }, }, { testName: "three files", input: "MM\x00Myfile\x00M \x00MyOtherFile\x00 M\x00YetAnother\x00", output: []*models.CommitFile{ { Path: "Myfile", ChangeStatus: "MM", }, { Path: "MyOtherFile", ChangeStatus: "M ", }, { Path: "YetAnother", ChangeStatus: " M", }, }, }, } for _, test := range tests { t.Run(test.testName, func(t *testing.T) { result := getCommitFilesFromFilenames(test.input) assert.Equal(t, test.output, result) }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/commit_loader.go000066400000000000000000000466311500612110400241350ustar00rootroot00000000000000package git_commands import ( "bytes" "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) // context: // here we get the commits from git log but format them to show whether they're // unpushed/pushed/merged into the base branch or not, or if they're yet to // be processed as part of a rebase (these won't appear in git log but we // grab them from the rebase-related files in the .git directory to show them // CommitLoader returns a list of Commit objects for the current repo type CommitLoader struct { *common.Common cmd oscommands.ICmdObjBuilder getWorkingTreeState func() models.WorkingTreeState readFile func(filename string) ([]byte, error) walkFiles func(root string, fn filepath.WalkFunc) error dotGitDir string *GitCommon } // making our dependencies explicit for the sake of easier testing func NewCommitLoader( cmn *common.Common, cmd oscommands.ICmdObjBuilder, getWorkingTreeState func() models.WorkingTreeState, gitCommon *GitCommon, ) *CommitLoader { return &CommitLoader{ Common: cmn, cmd: cmd, getWorkingTreeState: getWorkingTreeState, readFile: os.ReadFile, walkFiles: filepath.Walk, GitCommon: gitCommon, } } type GetCommitsOptions struct { Limit bool FilterPath string FilterAuthor string IncludeRebaseCommits bool RefName string // e.g. "HEAD" or "my_branch" RefForPushedStatus string // the ref to use for determining pushed/unpushed status // determines if we show the whole git graph i.e. pass the '--all' flag All bool // If non-empty, show divergence from this ref (left-right log) RefToShowDivergenceFrom string MainBranches *MainBranches HashPool *utils.StringPool } // GetCommits obtains the commits of the current branch func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit, error) { commits := []*models.Commit{} if opts.IncludeRebaseCommits && opts.FilterPath == "" { var err error commits, err = self.MergeRebasingCommits(opts.HashPool, commits) if err != nil { return nil, err } } wg := sync.WaitGroup{} wg.Add(2) var logErr error go utils.Safe(func() { defer wg.Done() logErr = self.getLogCmd(opts).RunAndProcessLines(func(line string) (bool, error) { commit := self.extractCommitFromLine(opts.HashPool, line, opts.RefToShowDivergenceFrom != "") commits = append(commits, commit) return false, nil }) }) var ancestor string var remoteAncestor string go utils.Safe(func() { defer wg.Done() ancestor = opts.MainBranches.GetMergeBase(opts.RefName) if opts.RefToShowDivergenceFrom != "" { remoteAncestor = opts.MainBranches.GetMergeBase(opts.RefToShowDivergenceFrom) } }) passedFirstPushedCommit := false // I can get this before firstPushedCommit, err := self.getFirstPushedCommit(opts.RefForPushedStatus) if err != nil { // must have no upstream branch so we'll consider everything as pushed passedFirstPushedCommit = true } wg.Wait() if logErr != nil { return nil, logErr } for _, commit := range commits { if commit.Hash() == firstPushedCommit { passedFirstPushedCommit = true } if !commit.IsTODO() { if passedFirstPushedCommit { commit.Status = models.StatusPushed } else { commit.Status = models.StatusUnpushed } } } if len(commits) == 0 { return commits, nil } if opts.RefToShowDivergenceFrom != "" { sort.SliceStable(commits, func(i, j int) bool { // In the divergence view we want incoming commits to come first return commits[i].Divergence > commits[j].Divergence }) _, localSectionStart, found := lo.FindIndexOf(commits, func(commit *models.Commit) bool { return commit.Divergence == models.DivergenceLeft }) if !found { localSectionStart = len(commits) } setCommitMergedStatuses(remoteAncestor, commits[:localSectionStart]) setCommitMergedStatuses(ancestor, commits[localSectionStart:]) } else { setCommitMergedStatuses(ancestor, commits) } return commits, nil } func (self *CommitLoader) MergeRebasingCommits(hashPool *utils.StringPool, commits []*models.Commit) ([]*models.Commit, error) { // chances are we have as many commits as last time so we'll set the capacity to be the old length result := make([]*models.Commit, 0, len(commits)) for i, commit := range commits { if !commit.IsTODO() { // removing the existing rebase commits so we can add the refreshed ones result = append(result, commits[i:]...) break } } workingTreeState := self.getWorkingTreeState() addConflictedRebasingCommit := true if workingTreeState.CherryPicking || workingTreeState.Reverting { sequencerCommits, err := self.getHydratedSequencerCommits(hashPool, workingTreeState) if err != nil { return nil, err } result = append(sequencerCommits, result...) addConflictedRebasingCommit = false } if workingTreeState.Rebasing { rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, addConflictedRebasingCommit) if err != nil { return nil, err } if len(rebasingCommits) > 0 { result = append(rebasingCommits, result...) } } return result, nil } // extractCommitFromLine takes a line from a git log and extracts the hash, message, date, and tag if present // then puts them into a commit object // example input: // 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line string, showDivergence bool) *models.Commit { split := strings.SplitN(line, "\x00", 8) hash := split[0] unixTimestamp := split[1] authorName := split[2] authorEmail := split[3] extraInfo := strings.TrimSpace(split[4]) parentHashes := split[5] divergence := models.DivergenceNone if showDivergence { divergence = lo.Ternary(split[6] == "<", models.DivergenceLeft, models.DivergenceRight) } message := split[7] tags := []string{} if extraInfo != "" { extraInfoFields := strings.Split(extraInfo, ",") for _, extraInfoField := range extraInfoFields { extraInfoField = strings.TrimSpace(extraInfoField) re := regexp.MustCompile(`tag: (.+)`) tagMatch := re.FindStringSubmatch(extraInfoField) if len(tagMatch) > 1 { tags = append(tags, tagMatch[1]) } } extraInfo = "(" + extraInfo + ")" } unitTimestampInt, _ := strconv.Atoi(unixTimestamp) parents := []string{} if len(parentHashes) > 0 { parents = strings.Split(parentHashes, " ") } return models.NewCommit(hashPool, models.NewCommitOpts{ Hash: hash, Name: message, Tags: tags, ExtraInfo: extraInfo, UnixTimestamp: int64(unitTimestampInt), AuthorName: authorName, AuthorEmail: authorEmail, Parents: parents, Divergence: divergence, }) } func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, addConflictingCommit bool) ([]*models.Commit, error) { todoFileHasShortHashes := self.version.IsOlderThan(2, 25, 2) return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), todoFileHasShortHashes) } func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool, workingTreeState models.WorkingTreeState) ([]*models.Commit, error) { commits := self.getSequencerCommits(hashPool) if len(commits) > 0 { // If we have any commits in .git/sequencer/todo, then the last one of // those is the conflicting one. commits[len(commits)-1].Status = models.StatusConflicted } else { // For single-commit cherry-picks and reverts, git apparently doesn't // use the sequencer; in that case, CHERRY_PICK_HEAD or REVERT_HEAD is // our conflicting commit, so synthesize it here. conflicedCommit := self.getConflictedSequencerCommit(hashPool, workingTreeState) if conflicedCommit != nil { commits = append(commits, conflicedCommit) } } return self.getHydratedTodoCommits(hashPool, commits, true) } func (self *CommitLoader) getHydratedTodoCommits(hashPool *utils.StringPool, todoCommits []*models.Commit, todoFileHasShortHashes bool) ([]*models.Commit, error) { if len(todoCommits) == 0 { return nil, nil } commitHashes := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) { return commit.Hash(), commit.Hash() != "" }) // note that we're not filtering these as we do non-rebasing commits just because // I suspect that will cause some damage cmdObj := self.cmd.New( NewGitCmd("show"). Config("log.showSignature=false"). Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat). Arg(commitHashes...). ToArgv(), ).DontLog() fullCommits := map[string]*models.Commit{} err := cmdObj.RunAndProcessLines(func(line string) (bool, error) { commit := self.extractCommitFromLine(hashPool, line, false) fullCommits[commit.Hash()] = commit return false, nil }) if err != nil { return nil, err } findFullCommit := lo.Ternary(todoFileHasShortHashes, func(hash string) *models.Commit { for s, c := range fullCommits { if strings.HasPrefix(s, hash) { return c } } return nil }, func(hash string) *models.Commit { return fullCommits[hash] }) hydratedCommits := make([]*models.Commit, 0, len(todoCommits)) for _, rebasingCommit := range todoCommits { if rebasingCommit.Hash() == "" { hydratedCommits = append(hydratedCommits, rebasingCommit) } else if commit := findFullCommit(rebasingCommit.Hash()); commit != nil { commit.Action = rebasingCommit.Action commit.Status = rebasingCommit.Status hydratedCommits = append(hydratedCommits, commit) } } return hydratedCommits, nil } // getRebasingCommits obtains the commits that we're in the process of rebasing // git-rebase-todo example: // pick ac446ae94ee560bdb8d1d057278657b251aaef17 ac446ae // pick afb893148791a2fbd8091aeb81deba4930c73031 afb8931 func (self *CommitLoader) getRebasingCommits(hashPool *utils.StringPool, addConflictingCommit bool) []*models.Commit { bytesContent, err := self.readFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")) if err != nil { self.Log.Error(fmt.Sprintf("error occurred reading git-rebase-todo: %s", err.Error())) // we assume an error means the file doesn't exist so we just return return nil } commits := []*models.Commit{} todos, err := todo.Parse(bytes.NewBuffer(bytesContent), self.config.GetCoreCommentChar()) if err != nil { self.Log.Error(fmt.Sprintf("error occurred while parsing git-rebase-todo file: %s", err.Error())) return nil } // See if the current commit couldn't be applied because it conflicted; if // so, add a fake entry for it if addConflictingCommit { if conflictedCommit := self.getConflictedCommit(hashPool, todos); conflictedCommit != nil { commits = append(commits, conflictedCommit) } } for _, t := range todos { if t.Command == todo.UpdateRef { t.Msg = t.Ref } else if t.Command == todo.Exec { t.Msg = t.ExecCommand } else if t.Commit == "" { // Command does not have a commit associated, skip continue } commits = utils.Prepend(commits, models.NewCommit(hashPool, models.NewCommitOpts{ Hash: t.Commit, Name: t.Msg, Status: models.StatusRebasing, Action: t.Command, })) } return commits } func (self *CommitLoader) getConflictedCommit(hashPool *utils.StringPool, todos []todo.Todo) *models.Commit { bytesContent, err := self.readFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/done")) if err != nil { self.Log.Error(fmt.Sprintf("error occurred reading rebase-merge/done: %s", err.Error())) return nil } doneTodos, err := todo.Parse(bytes.NewBuffer(bytesContent), self.config.GetCoreCommentChar()) if err != nil { self.Log.Error(fmt.Sprintf("error occurred while parsing rebase-merge/done file: %s", err.Error())) return nil } amendFileExists, _ := self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/amend")) messageFileExists, _ := self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/message")) return self.getConflictedCommitImpl(hashPool, todos, doneTodos, amendFileExists, messageFileExists) } func (self *CommitLoader) getConflictedCommitImpl(hashPool *utils.StringPool, todos []todo.Todo, doneTodos []todo.Todo, amendFileExists bool, messageFileExists bool) *models.Commit { // Should never be possible, but just to be safe: if len(doneTodos) == 0 { self.Log.Error("no done entries in rebase-merge/done file") return nil } lastTodo := doneTodos[len(doneTodos)-1] if lastTodo.Command == todo.Break || lastTodo.Command == todo.Exec || lastTodo.Command == todo.Reword { return nil } // In certain cases, git reschedules commands that failed. One example is if // a patch would overwrite an untracked file (another one is an "exec" that // failed, but we don't care about that here because we dealt with exec // already above). To detect this, compare the last command of the "done" // file against the first command of "git-rebase-todo"; if they are the // same, the command was rescheduled. if len(doneTodos) > 0 && len(todos) > 0 && doneTodos[len(doneTodos)-1] == todos[0] { // Command was rescheduled, no need to display it return nil } // Older versions of git have a bug whereby, if a command is rescheduled, // the last successful command is appended to the "done" file again. To // detect this, we need to compare the second-to-last done entry against the // first todo entry, and also compare the last done entry against the // last-but-two done entry; this latter check is needed for the following // case: // pick A // exec make test // pick B // exec make test // If pick B fails with conflicts, then the "done" file contains // pick A // exec make test // pick B // and git-rebase-todo contains // exec make test // Without the last condition we would erroneously treat this as the exec // command being rescheduled, so we wouldn't display our fake entry for // "pick B". if len(doneTodos) >= 3 && len(todos) > 0 && doneTodos[len(doneTodos)-2] == todos[0] && doneTodos[len(doneTodos)-1] == doneTodos[len(doneTodos)-3] { // Command was rescheduled, no need to display it return nil } if lastTodo.Command == todo.Edit { if amendFileExists { // Special case for "edit": if the "amend" file exists, the "edit" // command was successful, otherwise it wasn't return nil } if !messageFileExists { // As an additional check, see if the "message" file exists; if it // doesn't, it must be because a multi-commit cherry-pick or revert // was performed in the meantime, which deleted both the amend file // and the message file. return nil } } // I don't think this is ever possible, but again, just to be safe: if lastTodo.Commit == "" { self.Log.Error("last command in rebase-merge/done file doesn't have a commit") return nil } // Any other todo that has a commit associated with it must have failed with // a conflict, otherwise we wouldn't have stopped the rebase: return models.NewCommit(hashPool, models.NewCommitOpts{ Hash: lastTodo.Commit, Action: lastTodo.Command, Status: models.StatusConflicted, }) } func (self *CommitLoader) getSequencerCommits(hashPool *utils.StringPool) []*models.Commit { bytesContent, err := self.readFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "sequencer/todo")) if err != nil { self.Log.Error(fmt.Sprintf("error occurred reading sequencer/todo: %s", err.Error())) // we assume an error means the file doesn't exist so we just return return nil } commits := []*models.Commit{} todos, err := todo.Parse(bytes.NewBuffer(bytesContent), self.config.GetCoreCommentChar()) if err != nil { self.Log.Error(fmt.Sprintf("error occurred while parsing sequencer/todo file: %s", err.Error())) return nil } for _, t := range todos { if t.Commit == "" { // Command does not have a commit associated, skip continue } commits = utils.Prepend(commits, models.NewCommit(hashPool, models.NewCommitOpts{ Hash: t.Commit, Name: t.Msg, Status: models.StatusCherryPickingOrReverting, Action: t.Command, })) } return commits } func (self *CommitLoader) getConflictedSequencerCommit(hashPool *utils.StringPool, workingTreeState models.WorkingTreeState) *models.Commit { var shaFile string var action todo.TodoCommand if workingTreeState.CherryPicking { shaFile = "CHERRY_PICK_HEAD" action = todo.Pick } else if workingTreeState.Reverting { shaFile = "REVERT_HEAD" action = todo.Revert } else { return nil } bytesContent, err := self.readFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), shaFile)) if err != nil { self.Log.Error(fmt.Sprintf("error occurred reading %s: %s", shaFile, err.Error())) // we assume an error means the file doesn't exist so we just return return nil } lines := strings.Split(string(bytesContent), "\n") if len(lines) == 0 { return nil } return models.NewCommit(hashPool, models.NewCommitOpts{ Hash: lines[0], Status: models.StatusConflicted, Action: action, }) } func setCommitMergedStatuses(ancestor string, commits []*models.Commit) { if ancestor == "" { return } passedAncestor := false for i, commit := range commits { // some commits aren't really commits and don't have hashes, such as the update-ref todo if commit.Hash() != "" && strings.HasPrefix(ancestor, commit.Hash()) { passedAncestor = true } if commit.Status != models.StatusPushed && commit.Status != models.StatusUnpushed { continue } if passedAncestor { commits[i].Status = models.StatusMerged } } } func ignoringWarnings(commandOutput string) string { trimmedOutput := strings.TrimSpace(commandOutput) split := strings.Split(trimmedOutput, "\n") // need to get last line in case the first line is a warning about how the error is ambiguous. // At some point we should find a way to make it unambiguous lastLine := split[len(split)-1] return lastLine } // getFirstPushedCommit returns the first commit hash which has been pushed to the ref's upstream. // all commits above this are deemed unpushed and marked as such. func (self *CommitLoader) getFirstPushedCommit(refName string) (string, error) { output, err := self.cmd.New( NewGitCmd("merge-base"). Arg(refName). Arg(strings.TrimPrefix(refName, "refs/heads/") + "@{u}"). ToArgv(), ). DontLog(). RunWithOutput() if err != nil { return "", err } return ignoringWarnings(output), nil } // getLog gets the git log. func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj { gitLogOrder := self.AppState.GitLogOrder refSpec := opts.RefName if opts.RefToShowDivergenceFrom != "" { refSpec += "..." + opts.RefToShowDivergenceFrom } cmdArgs := NewGitCmd("log"). Arg(refSpec). ArgIf(gitLogOrder != "default", "--"+gitLogOrder). ArgIf(opts.All, "--all"). Arg("--oneline"). Arg(prettyFormat). Arg("--abbrev=40"). ArgIf(opts.FilterAuthor != "", "--author="+opts.FilterAuthor). ArgIf(opts.Limit, "-300"). ArgIf(opts.FilterPath != "", "--follow"). Arg("--no-show-signature"). ArgIf(opts.RefToShowDivergenceFrom != "", "--left-right"). Arg("--"). ArgIf(opts.FilterPath != "", opts.FilterPath). ToArgv() return self.cmd.New(cmdArgs).DontLog() } const prettyFormat = `--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%P%x00%m%x00%s` lazygit-0.50.0+ds1/pkg/commands/git_commands/commit_loader_test.go000066400000000000000000000544511500612110400251730ustar00rootroot00000000000000package git_commands import ( "path/filepath" "strings" "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" "github.com/stretchr/testify/assert" ) var commitsOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com|HEAD -> better-tests|b21997d6b4cbdf84b149|>|better typing for rebase mode b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164|1640824515|Jesse Duffield|jessedduffield@gmail.com|origin/better-tests|e94e8fc5b6fab4cb755f|>|fix logging e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c|1640823749|Jesse Duffield|jessedduffield@gmail.com|tag: 123, tag: 456|d8084cd558925eb7c9c3|>|refactor d8084cd558925eb7c9c38afeed5725c21653ab90|1640821426|Jesse Duffield|jessedduffield@gmail.com||65f910ebd85283b5cce9|>|WIP 65f910ebd85283b5cce9bf67d03d3f1a9ea3813a|1640821275|Jesse Duffield|jessedduffield@gmail.com||26c07b1ab33860a1a759|>|WIP 26c07b1ab33860a1a7591a0638f9925ccf497ffa|1640750752|Jesse Duffield|jessedduffield@gmail.com||3d4470a6c072208722e5|>|WIP 3d4470a6c072208722e5ae9a54bcb9634959a1c5|1640748818|Jesse Duffield|jessedduffield@gmail.com||053a66a7be3da43aacdc|>|WIP 053a66a7be3da43aacdc7aa78e1fe757b82c4dd2|1640739815|Jesse Duffield|jessedduffield@gmail.com||985fe482e806b172aea4|>|refactoring the config struct`, "|", "\x00", -1) var singleCommitOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com|HEAD -> better-tests|b21997d6b4cbdf84b149|>|better typing for rebase mode`, "|", "\x00", -1) func TestGetCommits(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner expectedCommitOpts []models.NewCommitOpts expectedError error logOrder string opts GetCommitsOptions mainBranches []string } scenarios := []scenario{ { testName: "should return no commits if there are none", logOrder: "topo-order", opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%P%x00%m%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil), expectedCommitOpts: []models.NewCommitOpts{}, expectedError: nil, }, { testName: "should use proper upstream name for branch", logOrder: "topo-order", opts: GetCommitsOptions{RefName: "refs/heads/mybranch", RefForPushedStatus: "refs/heads/mybranch", IncludeRebaseCommits: false}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"merge-base", "refs/heads/mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"log", "refs/heads/mybranch", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%P%x00%m%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil), expectedCommitOpts: []models.NewCommitOpts{}, expectedError: nil, }, { testName: "should return commits if they are present", logOrder: "topo-order", opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false}, mainBranches: []string{"master", "main", "develop"}, runner: oscommands.NewFakeRunner(t). // here it's seeing which commits are yet to be pushed ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). // here it's actually getting all the commits in a formatted form, one per line ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%P%x00%m%x00%s", "--abbrev=40", "--no-show-signature", "--"}, commitsOutput, nil). // here it's testing which of the configured main branches have an upstream ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil). // this one does ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")). // this one doesn't, so it checks origin instead ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"}, "", nil). // yep, origin/main exists ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "develop@{u}"}, "", errors.New("error")). // this one doesn't, so it checks origin instead ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/develop"}, "", errors.New("error")). // doesn't exist there, either, so it checks for a local branch ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/heads/develop"}, "", errors.New("error")). // no local branch either // here it's seeing where our branch diverged from the master branch so that we can mark that commit and parent commits as 'merged' ExpectGitArgs([]string{"merge-base", "HEAD", "refs/remotes/origin/master", "refs/remotes/origin/main"}, "26c07b1ab33860a1a7591a0638f9925ccf497ffa", nil), expectedCommitOpts: []models.NewCommitOpts{ { Hash: "0eea75e8c631fba6b58135697835d58ba4c18dbc", Name: "better typing for rebase mode", Status: models.StatusUnpushed, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "(HEAD -> better-tests)", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640826609, Parents: []string{ "b21997d6b4cbdf84b149", }, }, { Hash: "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", Name: "fix logging", Status: models.StatusPushed, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "(origin/better-tests)", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640824515, Parents: []string{ "e94e8fc5b6fab4cb755f", }, }, { Hash: "e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c", Name: "refactor", Status: models.StatusPushed, Action: models.ActionNone, Tags: []string{"123", "456"}, ExtraInfo: "(tag: 123, tag: 456)", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640823749, Parents: []string{ "d8084cd558925eb7c9c3", }, }, { Hash: "d8084cd558925eb7c9c38afeed5725c21653ab90", Name: "WIP", Status: models.StatusPushed, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640821426, Parents: []string{ "65f910ebd85283b5cce9", }, }, { Hash: "65f910ebd85283b5cce9bf67d03d3f1a9ea3813a", Name: "WIP", Status: models.StatusPushed, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640821275, Parents: []string{ "26c07b1ab33860a1a759", }, }, { Hash: "26c07b1ab33860a1a7591a0638f9925ccf497ffa", Name: "WIP", Status: models.StatusMerged, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640750752, Parents: []string{ "3d4470a6c072208722e5", }, }, { Hash: "3d4470a6c072208722e5ae9a54bcb9634959a1c5", Name: "WIP", Status: models.StatusMerged, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640748818, Parents: []string{ "053a66a7be3da43aacdc", }, }, { Hash: "053a66a7be3da43aacdc7aa78e1fe757b82c4dd2", Name: "refactoring the config struct", Status: models.StatusMerged, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640739815, Parents: []string{ "985fe482e806b172aea4", }, }, }, expectedError: nil, }, { testName: "should not call merge-base for mainBranches if none exist", logOrder: "topo-order", opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false}, mainBranches: []string{"master", "main"}, runner: oscommands.NewFakeRunner(t). // here it's seeing which commits are yet to be pushed ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). // here it's actually getting all the commits in a formatted form, one per line ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%P%x00%m%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil). // here it's testing which of the configured main branches exist; neither does ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/master"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/heads/master"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/heads/main"}, "", errors.New("error")), expectedCommitOpts: []models.NewCommitOpts{ { Hash: "0eea75e8c631fba6b58135697835d58ba4c18dbc", Name: "better typing for rebase mode", Status: models.StatusUnpushed, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "(HEAD -> better-tests)", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640826609, Parents: []string{ "b21997d6b4cbdf84b149", }, }, }, expectedError: nil, }, { testName: "should call merge-base for all main branches that exist", logOrder: "topo-order", opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false}, mainBranches: []string{"master", "main", "develop", "1.0-hotfixes"}, runner: oscommands.NewFakeRunner(t). // here it's seeing which commits are yet to be pushed ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). // here it's actually getting all the commits in a formatted form, one per line ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%P%x00%m%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil). // here it's testing which of the configured main branches exist ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil). ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/heads/main"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "develop@{u}"}, "refs/remotes/origin/develop", nil). ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "1.0-hotfixes@{u}"}, "refs/remotes/origin/1.0-hotfixes", nil). // here it's seeing where our branch diverged from the master branch so that we can mark that commit and parent commits as 'merged' ExpectGitArgs([]string{"merge-base", "HEAD", "refs/remotes/origin/master", "refs/remotes/origin/develop", "refs/remotes/origin/1.0-hotfixes"}, "26c07b1ab33860a1a7591a0638f9925ccf497ffa", nil), expectedCommitOpts: []models.NewCommitOpts{ { Hash: "0eea75e8c631fba6b58135697835d58ba4c18dbc", Name: "better typing for rebase mode", Status: models.StatusUnpushed, Action: models.ActionNone, Tags: []string{}, ExtraInfo: "(HEAD -> better-tests)", AuthorName: "Jesse Duffield", AuthorEmail: "jessedduffield@gmail.com", UnixTimestamp: 1640826609, Parents: []string{ "b21997d6b4cbdf84b149", }, }, }, expectedError: nil, }, { testName: "should not specify order if `log.order` is `default`", logOrder: "default", opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%P%x00%m%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil), expectedCommitOpts: []models.NewCommitOpts{}, expectedError: nil, }, { testName: "should set filter path", logOrder: "default", opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", FilterPath: "src"}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%P%x00%m%x00%s", "--abbrev=40", "--follow", "--no-show-signature", "--", "src"}, "", nil), expectedCommitOpts: []models.NewCommitOpts{}, expectedError: nil, }, } for _, scenario := range scenarios { t.Run(scenario.testName, func(t *testing.T) { common := utils.NewDummyCommon() common.AppState = &config.AppState{} common.AppState.GitLogOrder = scenario.logOrder cmd := oscommands.NewDummyCmdObjBuilder(scenario.runner) builder := &CommitLoader{ Common: common, cmd: cmd, getWorkingTreeState: func() models.WorkingTreeState { return models.WorkingTreeState{} }, dotGitDir: ".git", readFile: func(filename string) ([]byte, error) { return []byte(""), nil }, walkFiles: func(root string, fn filepath.WalkFunc) error { return nil }, } hashPool := &utils.StringPool{} common.UserConfig().Git.MainBranches = scenario.mainBranches opts := scenario.opts opts.MainBranches = NewMainBranches(common, cmd) opts.HashPool = hashPool commits, err := builder.GetCommits(opts) expectedCommits := lo.Map(scenario.expectedCommitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) assert.Equal(t, expectedCommits, commits) assert.Equal(t, scenario.expectedError, err) scenario.runner.CheckForMissingCalls() }) } } func TestCommitLoader_getConflictedCommitImpl(t *testing.T) { hashPool := &utils.StringPool{} scenarios := []struct { testName string todos []todo.Todo doneTodos []todo.Todo amendFileExists bool messageFileExists bool expectedResult *models.Commit }{ { testName: "no done todos", todos: []todo.Todo{}, doneTodos: []todo.Todo{}, amendFileExists: false, expectedResult: nil, }, { testName: "common case (conflict)", todos: []todo.Todo{}, doneTodos: []todo.Todo{ { Command: todo.Pick, Commit: "deadbeef", }, { Command: todo.Pick, Commit: "fa1afe1", }, }, amendFileExists: false, expectedResult: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "fa1afe1", Action: todo.Pick, Status: models.StatusConflicted, }), }, { testName: "last command was 'break'", todos: []todo.Todo{}, doneTodos: []todo.Todo{ {Command: todo.Break}, }, amendFileExists: false, expectedResult: nil, }, { testName: "last command was 'exec'", todos: []todo.Todo{}, doneTodos: []todo.Todo{ { Command: todo.Exec, ExecCommand: "make test", }, }, amendFileExists: false, expectedResult: nil, }, { testName: "last command was 'reword'", todos: []todo.Todo{}, doneTodos: []todo.Todo{ {Command: todo.Reword}, }, amendFileExists: false, expectedResult: nil, }, { testName: "'pick' was rescheduled", todos: []todo.Todo{ { Command: todo.Pick, Commit: "fa1afe1", }, }, doneTodos: []todo.Todo{ { Command: todo.Pick, Commit: "fa1afe1", }, }, amendFileExists: false, expectedResult: nil, }, { testName: "'pick' was rescheduled, buggy git version", todos: []todo.Todo{ { Command: todo.Pick, Commit: "fa1afe1", }, }, doneTodos: []todo.Todo{ { Command: todo.Pick, Commit: "deadbeaf", }, { Command: todo.Pick, Commit: "fa1afe1", }, { Command: todo.Pick, Commit: "deadbeaf", }, }, amendFileExists: false, expectedResult: nil, }, { testName: "conflicting 'pick' after 'exec'", todos: []todo.Todo{ { Command: todo.Exec, ExecCommand: "make test", }, }, doneTodos: []todo.Todo{ { Command: todo.Pick, Commit: "deadbeaf", }, { Command: todo.Exec, ExecCommand: "make test", }, { Command: todo.Pick, Commit: "fa1afe1", }, }, amendFileExists: false, expectedResult: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "fa1afe1", Action: todo.Pick, Status: models.StatusConflicted, }), }, { testName: "'edit' with amend file", todos: []todo.Todo{}, doneTodos: []todo.Todo{ { Command: todo.Edit, Commit: "fa1afe1", }, }, amendFileExists: true, expectedResult: nil, }, { testName: "'edit' without amend file but message file", todos: []todo.Todo{}, doneTodos: []todo.Todo{ { Command: todo.Edit, Commit: "fa1afe1", }, }, amendFileExists: false, messageFileExists: true, expectedResult: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "fa1afe1", Action: todo.Edit, Status: models.StatusConflicted, }), }, { testName: "'edit' without amend and without message file", todos: []todo.Todo{}, doneTodos: []todo.Todo{ { Command: todo.Edit, Commit: "fa1afe1", }, }, amendFileExists: false, messageFileExists: false, expectedResult: nil, }, } for _, scenario := range scenarios { t.Run(scenario.testName, func(t *testing.T) { common := utils.NewDummyCommon() builder := &CommitLoader{ Common: common, cmd: oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), getWorkingTreeState: func() models.WorkingTreeState { return models.WorkingTreeState{Rebasing: true} }, dotGitDir: ".git", readFile: func(filename string) ([]byte, error) { return []byte(""), nil }, walkFiles: func(root string, fn filepath.WalkFunc) error { return nil }, } hash := builder.getConflictedCommitImpl(hashPool, scenario.todos, scenario.doneTodos, scenario.amendFileExists, scenario.messageFileExists) assert.Equal(t, scenario.expectedResult, hash) }) } } func TestCommitLoader_setCommitMergedStatuses(t *testing.T) { type scenario struct { testName string commitOpts []models.NewCommitOpts ancestor string expectedCommitOpts []models.NewCommitOpts } scenarios := []scenario{ { testName: "basic", commitOpts: []models.NewCommitOpts{ {Hash: "12345", Name: "1", Action: models.ActionNone, Status: models.StatusUnpushed}, {Hash: "67890", Name: "2", Action: models.ActionNone, Status: models.StatusPushed}, {Hash: "abcde", Name: "3", Action: models.ActionNone, Status: models.StatusPushed}, }, ancestor: "67890", expectedCommitOpts: []models.NewCommitOpts{ {Hash: "12345", Name: "1", Action: models.ActionNone, Status: models.StatusUnpushed}, {Hash: "67890", Name: "2", Action: models.ActionNone, Status: models.StatusMerged}, {Hash: "abcde", Name: "3", Action: models.ActionNone, Status: models.StatusMerged}, }, }, { testName: "with update-ref", commitOpts: []models.NewCommitOpts{ {Hash: "12345", Name: "1", Action: models.ActionNone, Status: models.StatusUnpushed}, {Hash: "", Name: "", Action: todo.UpdateRef, Status: models.StatusNone}, {Hash: "abcde", Name: "3", Action: models.ActionNone, Status: models.StatusPushed}, }, ancestor: "deadbeef", expectedCommitOpts: []models.NewCommitOpts{ {Hash: "12345", Name: "1", Action: models.ActionNone, Status: models.StatusUnpushed}, {Hash: "", Name: "", Action: todo.UpdateRef, Status: models.StatusNone}, {Hash: "abcde", Name: "3", Action: models.ActionNone, Status: models.StatusPushed}, }, }, } for _, scenario := range scenarios { t.Run(scenario.testName, func(t *testing.T) { hashPool := &utils.StringPool{} commits := lo.Map(scenario.commitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) setCommitMergedStatuses(scenario.ancestor, commits) expectedCommits := lo.Map(scenario.expectedCommitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) assert.Equal(t, expectedCommits, commits) }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/commit_test.go000066400000000000000000000362741500612110400236500ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/stretchr/testify/assert" ) func TestCommitRewordCommit(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner summary string description string } scenarios := []scenario{ { "Single line reword", oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"commit", "--allow-empty", "--amend", "--only", "-m", "test"}, "", nil), "test", "", }, { "Multi line reword", oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"commit", "--allow-empty", "--amend", "--only", "-m", "test", "-m", "line 2\nline 3"}, "", nil), "test", "line 2\nline 3", }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildCommitCommands(commonDeps{runner: s.runner}) assert.NoError(t, instance.RewordLastCommit(s.summary, s.description).Run()) s.runner.CheckForMissingCalls() }) } } func TestCommitResetToCommit(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--hard", "78976bc"}, "", nil) instance := buildCommitCommands(commonDeps{runner: runner}) assert.NoError(t, instance.ResetToCommit("78976bc", "hard", []string{})) runner.CheckForMissingCalls() } func TestCommitCommitCmdObj(t *testing.T) { type scenario struct { testName string summary string forceSkipHooks bool description string configSignoff bool configSkipHookPrefix string expectedArgs []string } scenarios := []scenario{ { testName: "Commit", summary: "test", forceSkipHooks: false, configSignoff: false, configSkipHookPrefix: "", expectedArgs: []string{"commit", "-m", "test"}, }, { testName: "Commit with --no-verify flag < only prefix", summary: "WIP: test", forceSkipHooks: false, configSignoff: false, configSkipHookPrefix: "WIP", expectedArgs: []string{"commit", "--no-verify", "-m", "WIP: test"}, }, { testName: "Commit with --no-verify flag < skip flag and prefix", summary: "WIP: test", forceSkipHooks: true, configSignoff: false, configSkipHookPrefix: "WIP", expectedArgs: []string{"commit", "--no-verify", "-m", "WIP: test"}, }, { testName: "Commit with --no-verify flag < skip flag no prefix", summary: "test", forceSkipHooks: true, configSignoff: false, configSkipHookPrefix: "WIP", expectedArgs: []string{"commit", "--no-verify", "-m", "test"}, }, { testName: "Commit with multiline message", summary: "line1", forceSkipHooks: false, description: "line2", configSignoff: false, configSkipHookPrefix: "", expectedArgs: []string{"commit", "-m", "line1", "-m", "line2"}, }, { testName: "Commit with signoff", summary: "test", forceSkipHooks: false, configSignoff: true, configSkipHookPrefix: "", expectedArgs: []string{"commit", "--signoff", "-m", "test"}, }, { testName: "Commit with signoff and no-verify", summary: "WIP: test", forceSkipHooks: true, configSignoff: true, configSkipHookPrefix: "WIP", expectedArgs: []string{"commit", "--no-verify", "--signoff", "-m", "WIP: test"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() userConfig.Git.Commit.SignOff = s.configSignoff userConfig.Git.SkipHookPrefix = s.configSkipHookPrefix runner := oscommands.NewFakeRunner(t).ExpectGitArgs(s.expectedArgs, "", nil) instance := buildCommitCommands(commonDeps{userConfig: userConfig, runner: runner}) assert.NoError(t, instance.CommitCmdObj(s.summary, s.description, s.forceSkipHooks).Run()) runner.CheckForMissingCalls() }) } } func TestCommitCommitEditorCmdObj(t *testing.T) { type scenario struct { testName string configSignoff bool expected []string } scenarios := []scenario{ { testName: "Commit using editor", configSignoff: false, expected: []string{"commit"}, }, { testName: "Commit with --signoff", configSignoff: true, expected: []string{"commit", "--signoff"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() userConfig.Git.Commit.SignOff = s.configSignoff runner := oscommands.NewFakeRunner(t).ExpectGitArgs(s.expected, "", nil) instance := buildCommitCommands(commonDeps{userConfig: userConfig, runner: runner}) assert.NoError(t, instance.CommitEditorCmdObj().Run()) runner.CheckForMissingCalls() }) } } func TestCommitCreateFixupCommit(t *testing.T) { type scenario struct { testName string hash string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "valid case", hash: "12345", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"commit", "--fixup=12345"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildCommitCommands(commonDeps{runner: s.runner}) s.test(instance.CreateFixupCommit(s.hash)) s.runner.CheckForMissingCalls() }) } } func TestCommitCreateAmendCommit(t *testing.T) { type scenario struct { testName string originalSubject string newSubject string newDescription string includeFileChanges bool runner *oscommands.FakeCmdObjRunner } scenarios := []scenario{ { testName: "subject only", originalSubject: "original subject", newSubject: "new subject", newDescription: "", includeFileChanges: true, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"commit", "-m", "amend! original subject", "-m", "new subject"}, "", nil), }, { testName: "subject and description", originalSubject: "original subject", newSubject: "new subject", newDescription: "new description", includeFileChanges: true, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"commit", "-m", "amend! original subject", "-m", "new subject\n\nnew description"}, "", nil), }, { testName: "without file changes", originalSubject: "original subject", newSubject: "new subject", newDescription: "", includeFileChanges: false, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"commit", "-m", "amend! original subject", "-m", "new subject", "--only", "--allow-empty"}, "", nil), }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildCommitCommands(commonDeps{runner: s.runner}) err := instance.CreateAmendCommit(s.originalSubject, s.newSubject, s.newDescription, s.includeFileChanges) assert.NoError(t, err) s.runner.CheckForMissingCalls() }) } } func TestCommitShowCmdObj(t *testing.T) { type scenario struct { testName string filterPath string contextSize uint64 similarityThreshold int ignoreWhitespace bool extDiffCmd string expected []string } scenarios := []scenario{ { testName: "Default case without filter path", filterPath: "", contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, extDiffCmd: "", expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%"}, }, { testName: "Default case with filter path", filterPath: "file.txt", contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, extDiffCmd: "", expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--", "file.txt"}, }, { testName: "Show diff with custom context size", filterPath: "", contextSize: 77, similarityThreshold: 50, ignoreWhitespace: false, extDiffCmd: "", expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=77", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%"}, }, { testName: "Show diff with custom similarity threshold", filterPath: "", contextSize: 3, similarityThreshold: 33, ignoreWhitespace: false, extDiffCmd: "", expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=33%"}, }, { testName: "Show diff, ignoring whitespace", filterPath: "", contextSize: 77, similarityThreshold: 50, ignoreWhitespace: true, extDiffCmd: "", expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=77", "--stat", "--decorate", "-p", "1234567890", "--ignore-all-space", "--find-renames=50%"}, }, { testName: "Show diff with external diff command", filterPath: "", contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, extDiffCmd: "difft --color=always", expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() userConfig.Git.Paging.ExternalDiffCommand = s.extDiffCmd appState := &config.AppState{} appState.IgnoreWhitespaceInDiffView = s.ignoreWhitespace appState.DiffContextSize = s.contextSize appState.RenameSimilarityThreshold = s.similarityThreshold runner := oscommands.NewFakeRunner(t).ExpectGitArgs(s.expected, "", nil) repoPaths := RepoPaths{ worktreePath: "/path/to/worktree", } instance := buildCommitCommands(commonDeps{userConfig: userConfig, appState: appState, runner: runner, repoPaths: &repoPaths}) assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPath).Run()) runner.CheckForMissingCalls() }) } } func TestGetCommitMsg(t *testing.T) { type scenario struct { testName string input string expectedOutput string } scenarios := []scenario{ { "empty", ``, ``, }, { "no line breaks (single line)", `use generics to DRY up context code`, `use generics to DRY up context code`, }, { "with line breaks", `Merge pull request #1750 from mark2185/fix-issue-template 'git-rev parse' should be 'git rev-parse'`, `Merge pull request #1750 from mark2185/fix-issue-template 'git-rev parse' should be 'git rev-parse'`, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildCommitCommands(commonDeps{ runner: oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"-c", "log.showsignature=false", "log", "--format=%B", "--max-count=1", "deadbeef"}, s.input, nil), }) output, err := instance.GetCommitMessage("deadbeef") assert.NoError(t, err) assert.Equal(t, s.expectedOutput, output) }) } } func TestGetCommitMessageFromHistory(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner test func(string, error) } scenarios := []scenario{ { "Empty message", oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"log", "-1", "--skip=2", "--pretty=%H"}, "", nil).ExpectGitArgs([]string{"-c", "log.showsignature=false", "log", "--format=%B", "--max-count=1"}, "", nil), func(output string, err error) { assert.Error(t, err) }, }, { "Default case to retrieve a commit in history", oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"log", "-1", "--skip=2", "--pretty=%H"}, "hash3 \n", nil).ExpectGitArgs([]string{"-c", "log.showsignature=false", "log", "--format=%B", "--max-count=1", "hash3"}, `use generics to DRY up context code`, nil), func(output string, err error) { assert.NoError(t, err) assert.Equal(t, "use generics to DRY up context code", output) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildCommitCommands(commonDeps{runner: s.runner}) output, err := instance.GetCommitMessageFromHistory(2) s.test(output, err) }) } } func TestAddCoAuthorToMessage(t *testing.T) { scenarios := []struct { name string message string expectedResult string }{ { // This never happens, I think it isn't possible to create a commit // with an empty message. Just including it for completeness. name: "Empty message", message: "", expectedResult: "\n\nCo-authored-by: John Doe ", }, { name: "Just a subject, no body", message: "Subject", expectedResult: "Subject\n\nCo-authored-by: John Doe ", }, { name: "Subject and body", message: "Subject\n\nBody", expectedResult: "Subject\n\nBody\n\nCo-authored-by: John Doe ", }, { name: "Body already ending with a Co-authored-by line", message: "Subject\n\nBody\n\nCo-authored-by: Jane Smith ", expectedResult: "Subject\n\nBody\n\nCo-authored-by: Jane Smith \nCo-authored-by: John Doe ", }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result := AddCoAuthorToMessage(s.message, "John Doe ") assert.Equal(t, s.expectedResult, result) }) } } func TestAddCoAuthorToDescription(t *testing.T) { scenarios := []struct { name string description string expectedResult string }{ { name: "Empty description", description: "", expectedResult: "Co-authored-by: John Doe ", }, { name: "Non-empty description", description: "Body", expectedResult: "Body\n\nCo-authored-by: John Doe ", }, { name: "Description already ending with a Co-authored-by line", description: "Body\n\nCo-authored-by: Jane Smith ", expectedResult: "Body\n\nCo-authored-by: Jane Smith \nCo-authored-by: John Doe ", }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result := AddCoAuthorToDescription(s.description, "John Doe ") assert.Equal(t, s.expectedResult, result) }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/common.go000066400000000000000000000014221500612110400225740ustar00rootroot00000000000000package git_commands import ( gogit "github.com/jesseduffield/go-git/v5" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" ) type GitCommon struct { *common.Common version *GitVersion cmd oscommands.ICmdObjBuilder os *oscommands.OSCommand repoPaths *RepoPaths repo *gogit.Repository config *ConfigCommands } func NewGitCommon( cmn *common.Common, version *GitVersion, cmd oscommands.ICmdObjBuilder, osCommand *oscommands.OSCommand, repoPaths *RepoPaths, repo *gogit.Repository, config *ConfigCommands, ) *GitCommon { return &GitCommon{ Common: cmn, version: version, cmd: cmd, os: osCommand, repoPaths: repoPaths, repo: repo, config: config, } } lazygit-0.50.0+ds1/pkg/commands/git_commands/config.go000066400000000000000000000066211500612110400225570ustar00rootroot00000000000000package git_commands import ( "os" "strconv" "strings" gogit "github.com/jesseduffield/go-git/v5" "github.com/jesseduffield/go-git/v5/config" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" ) type ConfigCommands struct { *common.Common gitConfig git_config.IGitConfig repo *gogit.Repository } func NewConfigCommands( common *common.Common, gitConfig git_config.IGitConfig, repo *gogit.Repository, ) *ConfigCommands { return &ConfigCommands{ Common: common, gitConfig: gitConfig, repo: repo, } } func (self *ConfigCommands) ConfiguredPager() string { if os.Getenv("GIT_PAGER") != "" { return os.Getenv("GIT_PAGER") } if os.Getenv("PAGER") != "" { return os.Getenv("PAGER") } output := self.gitConfig.Get("core.pager") return strings.Split(output, "\n")[0] } func (self *ConfigCommands) GetPager(width int) string { useConfig := self.UserConfig().Git.Paging.UseConfig if useConfig { pager := self.ConfiguredPager() return strings.Split(pager, "| less")[0] } templateValues := map[string]string{ "columnWidth": strconv.Itoa(width/2 - 6), } pagerTemplate := string(self.UserConfig().Git.Paging.Pager) return utils.ResolvePlaceholderString(pagerTemplate, templateValues) } type GpgConfigKey string const ( CommitGpgSign GpgConfigKey = "commit.gpgSign" TagGpgSign GpgConfigKey = "tag.gpgSign" ) // NeedsGpgSubprocess tells us whether the user has gpg enabled for the specified action type // and needs a subprocess because they have a process where they manually // enter their password every time a GPG action is taken func (self *ConfigCommands) NeedsGpgSubprocess(key GpgConfigKey) bool { overrideGpg := self.UserConfig().Git.OverrideGpg if overrideGpg { return false } return self.gitConfig.GetBool(string(key)) } func (self *ConfigCommands) NeedsGpgSubprocessForCommit() bool { return self.NeedsGpgSubprocess(CommitGpgSign) } func (self *ConfigCommands) GetGpgTagSign() bool { return self.gitConfig.GetBool(string(TagGpgSign)) } func (self *ConfigCommands) GetCoreEditor() string { return self.gitConfig.Get("core.editor") } // GetRemoteURL returns current repo remote url func (self *ConfigCommands) GetRemoteURL() string { return self.gitConfig.Get("remote.origin.url") } func (self *ConfigCommands) GetShowUntrackedFiles() string { return self.gitConfig.Get("status.showUntrackedFiles") } // this determines whether the user has configured to push to the remote branch of the same name as the current or not func (self *ConfigCommands) GetPushToCurrent() bool { return self.gitConfig.Get("push.default") == "current" } // returns the repo's branches as specified in the git config func (self *ConfigCommands) Branches() (map[string]*config.Branch, error) { conf, err := self.repo.Config() if err != nil { return nil, err } return conf.Branches, nil } func (self *ConfigCommands) GetGitFlowPrefixes() string { return self.gitConfig.GetGeneral("--local --get-regexp gitflow.prefix") } func (self *ConfigCommands) GetCoreCommentChar() byte { if commentCharStr := self.gitConfig.Get("core.commentChar"); len(commentCharStr) == 1 { return commentCharStr[0] } return '#' } func (self *ConfigCommands) GetRebaseUpdateRefs() bool { return self.gitConfig.GetBool("rebase.updateRefs") } func (self *ConfigCommands) DropConfigCache() { self.gitConfig.DropCache() } lazygit-0.50.0+ds1/pkg/commands/git_commands/custom.go000066400000000000000000000020321500612110400226140ustar00rootroot00000000000000package git_commands import ( "fmt" "strings" "github.com/mgutz/str" ) type CustomCommands struct { *GitCommon } func NewCustomCommands(gitCommon *GitCommon) *CustomCommands { return &CustomCommands{ GitCommon: gitCommon, } } // Only to be used for the sake of running custom commands specified by the user. // If you want to run a new command, try finding a place for it in one of the neighbouring // files, or creating a new BlahCommands struct to hold it. func (self *CustomCommands) RunWithOutput(cmdStr string) (string, error) { return self.cmd.New(str.ToArgv(cmdStr)).RunWithOutput() } // A function that can be used as a "runCommand" entry in the template.FuncMap of templates. func (self *CustomCommands) TemplateFunctionRunCommand(cmdStr string) (string, error) { output, err := self.RunWithOutput(cmdStr) if err != nil { return "", err } output = strings.TrimRight(output, "\r\n") if strings.Contains(output, "\r\n") { return "", fmt.Errorf("command output contains newlines: %s", output) } return output, nil } lazygit-0.50.0+ds1/pkg/commands/git_commands/deps_test.go000066400000000000000000000104011500612110400232730ustar00rootroot00000000000000package git_commands import ( "os" "github.com/go-errors/errors" gogit "github.com/jesseduffield/go-git/v5" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/spf13/afero" ) type commonDeps struct { runner *oscommands.FakeCmdObjRunner userConfig *config.UserConfig appState *config.AppState gitVersion *GitVersion gitConfig *git_config.FakeGitConfig getenv func(string) string removeFile func(string) error common *common.Common cmd *oscommands.CmdObjBuilder fs afero.Fs repoPaths *RepoPaths } func buildGitCommon(deps commonDeps) *GitCommon { gitCommon := &GitCommon{} gitCommon.Common = deps.common if gitCommon.Common == nil { gitCommon.Common = utils.NewDummyCommonWithUserConfigAndAppState(deps.userConfig, deps.appState) } if deps.fs != nil { gitCommon.Fs = deps.fs } if deps.repoPaths != nil { gitCommon.repoPaths = deps.repoPaths } else { gitCommon.repoPaths = MockRepoPaths(".git") } runner := deps.runner if runner == nil { runner = oscommands.NewFakeRunner(nil) } cmd := deps.cmd // gotta check deps.cmd because it's not an interface type and an interface value of nil is not considered to be nil if cmd == nil { cmd = oscommands.NewDummyCmdObjBuilder(runner) } gitCommon.cmd = cmd gitCommon.Common.SetUserConfig(deps.userConfig) if gitCommon.Common.UserConfig() == nil { gitCommon.Common.SetUserConfig(config.GetDefaultConfig()) } gitCommon.version = deps.gitVersion if gitCommon.version == nil { gitCommon.version = &GitVersion{2, 0, 0, ""} } gitConfig := deps.gitConfig if gitConfig == nil { gitConfig = git_config.NewFakeGitConfig(nil) } gitCommon.repo = buildRepo() gitCommon.config = NewConfigCommands(gitCommon.Common, gitConfig, gitCommon.repo) getenv := deps.getenv if getenv == nil { getenv = func(string) string { return "" } } removeFile := deps.removeFile if removeFile == nil { removeFile = func(string) error { return errors.New("unexpected call to removeFile") } } gitCommon.os = oscommands.NewDummyOSCommandWithDeps(oscommands.OSCommandDeps{ Common: gitCommon.Common, GetenvFn: getenv, Cmd: cmd, RemoveFileFn: removeFile, TempDir: os.TempDir(), }) return gitCommon } func buildRepo() *gogit.Repository { // TODO: think of a way to actually mock this out var repo *gogit.Repository = nil return repo } func buildFileLoader(gitCommon *GitCommon) *FileLoader { return NewFileLoader(gitCommon, gitCommon.cmd, gitCommon.config) } func buildSubmoduleCommands(deps commonDeps) *SubmoduleCommands { gitCommon := buildGitCommon(deps) return NewSubmoduleCommands(gitCommon) } func buildCommitCommands(deps commonDeps) *CommitCommands { gitCommon := buildGitCommon(deps) return NewCommitCommands(gitCommon) } func buildWorkingTreeCommands(deps commonDeps) *WorkingTreeCommands { gitCommon := buildGitCommon(deps) submoduleCommands := buildSubmoduleCommands(deps) fileLoader := buildFileLoader(gitCommon) return NewWorkingTreeCommands(gitCommon, submoduleCommands, fileLoader) } func buildStashCommands(deps commonDeps) *StashCommands { gitCommon := buildGitCommon(deps) fileLoader := buildFileLoader(gitCommon) workingTreeCommands := buildWorkingTreeCommands(deps) return NewStashCommands(gitCommon, fileLoader, workingTreeCommands) } func buildRebaseCommands(deps commonDeps) *RebaseCommands { gitCommon := buildGitCommon(deps) workingTreeCommands := buildWorkingTreeCommands(deps) commitCommands := buildCommitCommands(deps) return NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands) } func buildSyncCommands(deps commonDeps) *SyncCommands { gitCommon := buildGitCommon(deps) return NewSyncCommands(gitCommon) } func buildFileCommands(deps commonDeps) *FileCommands { gitCommon := buildGitCommon(deps) return NewFileCommands(gitCommon) } func buildBranchCommands(deps commonDeps) *BranchCommands { gitCommon := buildGitCommon(deps) return NewBranchCommands(gitCommon) } func buildFlowCommands(deps commonDeps) *FlowCommands { gitCommon := buildGitCommon(deps) return NewFlowCommands(gitCommon) } lazygit-0.50.0+ds1/pkg/commands/git_commands/diff.go000066400000000000000000000066231500612110400222240ustar00rootroot00000000000000package git_commands import ( "fmt" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) type DiffCommands struct { *GitCommon } func NewDiffCommands(gitCommon *GitCommon) *DiffCommands { return &DiffCommands{ GitCommon: gitCommon, } } // This is for generating diffs to be shown in the UI (e.g. rendering a range // diff to the main view). It uses a custom pager if one is configured. func (self *DiffCommands) DiffCmdObj(diffArgs []string) oscommands.ICmdObj { extDiffCmd := self.UserConfig().Git.Paging.ExternalDiffCommand useExtDiff := extDiffCmd != "" ignoreWhitespace := self.AppState.IgnoreWhitespaceInDiffView return self.cmd.New( NewGitCmd("diff"). Config("diff.noprefix=false"). ConfigIf(useExtDiff, "diff.external="+extDiffCmd). ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff"). Arg("--submodule"). Arg(fmt.Sprintf("--color=%s", self.UserConfig().Git.Paging.ColorArg)). ArgIf(ignoreWhitespace, "--ignore-all-space"). Arg(fmt.Sprintf("--unified=%d", self.AppState.DiffContextSize)). Arg(diffArgs...). Dir(self.repoPaths.worktreePath). ToArgv(), ) } // This is a basic generic diff command that can be used for any diff operation // (e.g. copying a diff to the clipboard). It will not use a custom pager, and // does not use user configs such as ignore whitespace. // If you want to diff specific refs (one or two), you need to add them yourself // in additionalArgs; it is recommended to also pass `--` after that. If you // want to restrict the diff to specific paths, pass them in additionalArgs // after the `--`. func (self *DiffCommands) GetDiff(staged bool, additionalArgs ...string) (string, error) { return self.cmd.New( NewGitCmd("diff"). Config("diff.noprefix=false"). Arg("--no-ext-diff", "--no-color"). ArgIf(staged, "--staged"). Dir(self.repoPaths.worktreePath). Arg(additionalArgs...). ToArgv(), ).RunWithOutput() } type DiffToolCmdOptions struct { // The path to show a diff for. Pass "." for the entire repo. Filepath string // The commit against which to show the diff. Leave empty to show a diff of // the working copy. FromCommit string // The commit to diff against FromCommit. Leave empty to diff the working // copy against FromCommit. Leave both FromCommit and ToCommit empty to show // the diff of the unstaged working copy changes against the index if Staged // is false, or the staged changes against HEAD if Staged is true. ToCommit string // Whether to reverse the left and right sides of the diff. Reverse bool // Whether the given Filepath is a directory. We'll pass --dir-diff to // git-difftool in that case. IsDirectory bool // Whether to show the staged or the unstaged changes. Must be false if both // FromCommit and ToCommit are non-empty. Staged bool } func (self *DiffCommands) OpenDiffToolCmdObj(opts DiffToolCmdOptions) oscommands.ICmdObj { return self.cmd.New(NewGitCmd("difftool"). Arg("--no-prompt"). ArgIf(opts.IsDirectory, "--dir-diff"). ArgIf(opts.Staged, "--cached"). ArgIf(opts.FromCommit != "", opts.FromCommit). ArgIf(opts.ToCommit != "", opts.ToCommit). ArgIf(opts.Reverse, "-R"). Arg("--", opts.Filepath). ToArgv()) } func (self *DiffCommands) DiffIndexCmdObj(diffArgs ...string) oscommands.ICmdObj { return self.cmd.New( NewGitCmd("diff-index"). Config("diff.noprefix=false"). Arg("--submodule", "--no-ext-diff", "--no-color", "--patch"). Arg(diffArgs...).ToArgv(), ) } lazygit-0.50.0+ds1/pkg/commands/git_commands/file.go000066400000000000000000000120231500612110400222220ustar00rootroot00000000000000package git_commands import ( "os" "strconv" "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type FileCommands struct { *GitCommon } func NewFileCommands(gitCommon *GitCommon) *FileCommands { return &FileCommands{ GitCommon: gitCommon, } } // Cat obtains the content of a file func (self *FileCommands) Cat(fileName string) (string, error) { buf, err := os.ReadFile(fileName) if err != nil { return "", nil } return string(buf), nil } func (self *FileCommands) GetEditCmdStrLegacy(filename string, lineNumber int) (string, error) { editor := self.UserConfig().OS.EditCommand if editor == "" { editor = self.config.GetCoreEditor() } if editor == "" { editor = self.os.Getenv("GIT_EDITOR") } if editor == "" { editor = self.os.Getenv("VISUAL") } if editor == "" { editor = self.os.Getenv("EDITOR") } if editor == "" { if err := self.cmd.New([]string{"which", "vi"}).DontLog().Run(); err == nil { editor = "vi" } } if editor == "" { return "", errors.New("No editor defined in config file, $GIT_EDITOR, $VISUAL, $EDITOR, or git config") } templateValues := map[string]string{ "editor": editor, "filename": self.cmd.Quote(filename), "line": strconv.Itoa(lineNumber), } editCmdTemplate := self.UserConfig().OS.EditCommandTemplate if len(editCmdTemplate) == 0 { switch editor { case "emacs", "nano", "vi", "vim", "nvim": editCmdTemplate = "{{editor}} +{{line}} -- {{filename}}" case "subl": editCmdTemplate = "{{editor}} -- {{filename}}:{{line}}" case "code": editCmdTemplate = "{{editor}} -r --goto -- {{filename}}:{{line}}" default: editCmdTemplate = "{{editor}} -- {{filename}}" } } return utils.ResolvePlaceholderString(editCmdTemplate, templateValues), nil } func (self *FileCommands) GetEditCmdStr(filenames []string) (string, bool) { // Legacy support for old config; to be removed at some point if self.UserConfig().OS.Edit == "" && self.UserConfig().OS.EditCommandTemplate != "" { // If multiple files are selected, we'll simply edit just the first one. // It's not worth fixing this for the legacy support. if cmdStr, err := self.GetEditCmdStrLegacy(filenames[0], 1); err == nil { return cmdStr, true } } template, suspend := config.GetEditTemplate(self.os.Platform.Shell, &self.UserConfig().OS, self.guessDefaultEditor) quotedFilenames := lo.Map(filenames, func(filename string, _ int) string { return self.cmd.Quote(filename) }) templateValues := map[string]string{ "filename": strings.Join(quotedFilenames, " "), } cmdStr := utils.ResolvePlaceholderString(template, templateValues) return cmdStr, suspend } func (self *FileCommands) GetEditAtLineCmdStr(filename string, lineNumber int) (string, bool) { // Legacy support for old config; to be removed at some point if self.UserConfig().OS.EditAtLine == "" && self.UserConfig().OS.EditCommandTemplate != "" { if cmdStr, err := self.GetEditCmdStrLegacy(filename, lineNumber); err == nil { return cmdStr, true } } template, suspend := config.GetEditAtLineTemplate(self.os.Platform.Shell, &self.UserConfig().OS, self.guessDefaultEditor) templateValues := map[string]string{ "filename": self.cmd.Quote(filename), "line": strconv.Itoa(lineNumber), } cmdStr := utils.ResolvePlaceholderString(template, templateValues) return cmdStr, suspend } func (self *FileCommands) GetEditAtLineAndWaitCmdStr(filename string, lineNumber int) string { // Legacy support for old config; to be removed at some point if self.UserConfig().OS.EditAtLineAndWait == "" && self.UserConfig().OS.EditCommandTemplate != "" { if cmdStr, err := self.GetEditCmdStrLegacy(filename, lineNumber); err == nil { return cmdStr } } template := config.GetEditAtLineAndWaitTemplate(self.os.Platform.Shell, &self.UserConfig().OS, self.guessDefaultEditor) templateValues := map[string]string{ "filename": self.cmd.Quote(filename), "line": strconv.Itoa(lineNumber), } cmdStr := utils.ResolvePlaceholderString(template, templateValues) return cmdStr } func (self *FileCommands) GetOpenDirInEditorCmdStr(path string) (string, bool) { template, suspend := config.GetOpenDirInEditorTemplate(self.os.Platform.Shell, &self.UserConfig().OS, self.guessDefaultEditor) templateValues := map[string]string{ "dir": self.cmd.Quote(path), } cmdStr := utils.ResolvePlaceholderString(template, templateValues) return cmdStr, suspend } func (self *FileCommands) guessDefaultEditor() string { // Try to query a few places where editors get configured editor := self.config.GetCoreEditor() if editor == "" { editor = self.os.Getenv("GIT_EDITOR") } if editor == "" { editor = self.os.Getenv("VISUAL") } if editor == "" { editor = self.os.Getenv("EDITOR") } if editor != "" { // At this point, it might be more than just the name of the editor; // e.g. it might be "code -w" or "vim -u myvim.rc". So assume that // everything up to the first space is the editor name. editor = strings.Split(editor, " ")[0] } return editor } lazygit-0.50.0+ds1/pkg/commands/git_commands/file_loader.go000066400000000000000000000123541500612110400235570ustar00rootroot00000000000000package git_commands import ( "fmt" "path/filepath" "strconv" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) type FileLoaderConfig interface { GetShowUntrackedFiles() string } type FileLoader struct { *GitCommon cmd oscommands.ICmdObjBuilder config FileLoaderConfig getFileType func(string) string } func NewFileLoader(gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder, config FileLoaderConfig) *FileLoader { return &FileLoader{ GitCommon: gitCommon, cmd: cmd, getFileType: oscommands.FileType, config: config, } } type GetStatusFileOptions struct { NoRenames bool // If true, we'll show untracked files even if the user has set the config to hide them. // This is useful for users with bare repos for dotfiles who default to hiding untracked files, // but want to occasionally see them to `git add` a new file. ForceShowUntracked bool } func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File { // check if config wants us ignoring untracked files untrackedFilesSetting := self.config.GetShowUntrackedFiles() if opts.ForceShowUntracked || untrackedFilesSetting == "" { untrackedFilesSetting = "all" } untrackedFilesArg := fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting) statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg}) if err != nil { self.Log.Error(err) } files := []*models.File{} fileDiffs := map[string]FileDiff{} if self.GitCommon.Common.UserConfig().Gui.ShowNumstatInFilesView { fileDiffs, err = self.getFileDiffs() if err != nil { self.Log.Error(err) } } for _, status := range statuses { if strings.HasPrefix(status.StatusString, "warning") { self.Log.Warningf("warning when calling git status: %s", status.StatusString) continue } file := &models.File{ Path: status.Path, PreviousPath: status.PreviousPath, DisplayString: status.StatusString, } if diff, ok := fileDiffs[status.Path]; ok { file.LinesAdded = diff.LinesAdded file.LinesDeleted = diff.LinesDeleted } models.SetStatusFields(file, status.Change) files = append(files, file) } // Go through the files to see if any of these files are actually worktrees // so that we can render them correctly worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath()) for _, file := range files { for _, worktreePath := range worktreePaths { absFilePath, err := filepath.Abs(file.Path) if err != nil { self.Log.Error(err) continue } if absFilePath == worktreePath { file.IsWorktree = true // `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree // If we include the slash, it will be rendered as a folder with a null file inside. file.Path = strings.TrimSuffix(file.Path, "/") break } } } return files } type FileDiff struct { LinesAdded int LinesDeleted int } func (fileLoader *FileLoader) getFileDiffs() (map[string]FileDiff, error) { diffs, err := fileLoader.gitDiffNumStat() if err != nil { return nil, err } splitLines := strings.Split(diffs, "\x00") fileDiffs := map[string]FileDiff{} for _, line := range splitLines { splitLine := strings.Split(line, "\t") if len(splitLine) != 3 { continue } linesAdded, err := strconv.Atoi(splitLine[0]) if err != nil { continue } linesDeleted, err := strconv.Atoi(splitLine[1]) if err != nil { continue } fileName := splitLine[2] fileDiffs[fileName] = FileDiff{ LinesAdded: linesAdded, LinesDeleted: linesDeleted, } } return fileDiffs, nil } // GitStatus returns the file status of the repo type GitStatusOptions struct { NoRenames bool UntrackedFilesArg string } type FileStatus struct { StatusString string Change string // ??, MM, AM, ... Path string PreviousPath string } func (fileLoader *FileLoader) gitDiffNumStat() (string, error) { return fileLoader.cmd.New( NewGitCmd("diff"). Arg("--numstat"). Arg("-z"). Arg("HEAD"). ToArgv(), ).DontLog().RunWithOutput() } func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) { cmdArgs := NewGitCmd("status"). Arg(opts.UntrackedFilesArg). Arg("--porcelain"). Arg("-z"). ArgIfElse( opts.NoRenames, "--no-renames", fmt.Sprintf("--find-renames=%d%%", self.AppState.RenameSimilarityThreshold), ). ToArgv() statusLines, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs() if err != nil { return []FileStatus{}, err } splitLines := strings.Split(statusLines, "\x00") response := []FileStatus{} for i := 0; i < len(splitLines); i++ { original := splitLines[i] if len(original) < 3 { continue } status := FileStatus{ StatusString: original, Change: original[:2], Path: original[3:], PreviousPath: "", } if strings.HasPrefix(status.Change, "R") { // if a line starts with 'R' then the next line is the original file. status.PreviousPath = splitLines[i+1] status.StatusString = fmt.Sprintf("%s %s -> %s", status.Change, status.PreviousPath, status.Path) i++ } response = append(response, status) } return response, nil } lazygit-0.50.0+ds1/pkg/commands/git_commands/file_loader_test.go000066400000000000000000000161021500612110400246110ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/stretchr/testify/assert" ) func TestFileGetStatusFiles(t *testing.T) { type scenario struct { testName string similarityThreshold int runner oscommands.ICmdObjRunner showNumstatInFilesView bool expectedFiles []*models.File } scenarios := []scenario{ { testName: "No files found", similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil), expectedFiles: []*models.File{}, }, { testName: "Several files found", similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "MM file1.txt\x00A file3.txt\x00AM file2.txt\x00?? file4.txt\x00UU file5.txt", nil, ). ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"}, "4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt", nil, ), showNumstatInFilesView: true, expectedFiles: []*models.File{ { Path: "file1.txt", HasStagedChanges: true, HasUnstagedChanges: true, Tracked: true, Added: false, Deleted: false, HasMergeConflicts: false, HasInlineMergeConflicts: false, DisplayString: "MM file1.txt", ShortStatus: "MM", LinesAdded: 4, LinesDeleted: 1, }, { Path: "file3.txt", HasStagedChanges: true, HasUnstagedChanges: false, Tracked: false, Added: true, Deleted: false, HasMergeConflicts: false, HasInlineMergeConflicts: false, DisplayString: "A file3.txt", ShortStatus: "A ", LinesAdded: 2, LinesDeleted: 2, }, { Path: "file2.txt", HasStagedChanges: true, HasUnstagedChanges: true, Tracked: false, Added: true, Deleted: false, HasMergeConflicts: false, HasInlineMergeConflicts: false, DisplayString: "AM file2.txt", ShortStatus: "AM", LinesAdded: 1, LinesDeleted: 0, }, { Path: "file4.txt", HasStagedChanges: false, HasUnstagedChanges: true, Tracked: false, Added: true, Deleted: false, HasMergeConflicts: false, HasInlineMergeConflicts: false, DisplayString: "?? file4.txt", ShortStatus: "??", LinesAdded: 0, LinesDeleted: 2, }, { Path: "file5.txt", HasStagedChanges: false, HasUnstagedChanges: true, Tracked: true, Added: false, Deleted: false, HasMergeConflicts: true, HasInlineMergeConflicts: true, DisplayString: "UU file5.txt", ShortStatus: "UU", LinesAdded: 2, LinesDeleted: 2, }, }, }, { testName: "File with new line char", similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "MM a\nb.txt", nil), expectedFiles: []*models.File{ { Path: "a\nb.txt", HasStagedChanges: true, HasUnstagedChanges: true, Tracked: true, Added: false, Deleted: false, HasMergeConflicts: false, HasInlineMergeConflicts: false, DisplayString: "MM a\nb.txt", ShortStatus: "MM", }, }, }, { testName: "Renamed files", similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "R after1.txt\x00before1.txt\x00RM after2.txt\x00before2.txt", nil, ), expectedFiles: []*models.File{ { Path: "after1.txt", PreviousPath: "before1.txt", HasStagedChanges: true, HasUnstagedChanges: false, Tracked: true, Added: false, Deleted: false, HasMergeConflicts: false, HasInlineMergeConflicts: false, DisplayString: "R before1.txt -> after1.txt", ShortStatus: "R ", }, { Path: "after2.txt", PreviousPath: "before2.txt", HasStagedChanges: true, HasUnstagedChanges: true, Tracked: true, Added: false, Deleted: false, HasMergeConflicts: false, HasInlineMergeConflicts: false, DisplayString: "RM before2.txt -> after2.txt", ShortStatus: "RM", }, }, }, { testName: "File with arrow in name", similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, `?? a -> b.txt`, nil, ), expectedFiles: []*models.File{ { Path: "a -> b.txt", HasStagedChanges: false, HasUnstagedChanges: true, Tracked: false, Added: true, Deleted: false, HasMergeConflicts: false, HasInlineMergeConflicts: false, DisplayString: "?? a -> b.txt", ShortStatus: "??", }, }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { cmd := oscommands.NewDummyCmdObjBuilder(s.runner) appState := &config.AppState{} appState.RenameSimilarityThreshold = s.similarityThreshold userConfig := &config.UserConfig{ Gui: config.GuiConfig{ ShowNumstatInFilesView: s.showNumstatInFilesView, }, } loader := &FileLoader{ GitCommon: buildGitCommon(commonDeps{appState: appState, userConfig: userConfig}), cmd: cmd, config: &FakeFileLoaderConfig{showUntrackedFiles: "yes"}, getFileType: func(string) string { return "file" }, } assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{})) }) } } type FakeFileLoaderConfig struct { showUntrackedFiles string } func (self *FakeFileLoaderConfig) GetShowUntrackedFiles() string { return self.showUntrackedFiles } lazygit-0.50.0+ds1/pkg/commands/git_commands/file_test.go000066400000000000000000000227171500612110400232740ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/stretchr/testify/assert" ) func TestEditFileCmdStrLegacy(t *testing.T) { type scenario struct { filename string configEditCommand string configEditCommandTemplate string runner *oscommands.FakeCmdObjRunner getenv func(string) string gitConfigMockResponses map[string]string test func(string, error) } scenarios := []scenario{ { filename: "test", configEditCommand: "", configEditCommandTemplate: "{{editor}} {{filename}}", runner: oscommands.NewFakeRunner(t). ExpectArgs([]string{"which", "vi"}, "", errors.New("error")), getenv: func(env string) string { return "" }, gitConfigMockResponses: nil, test: func(cmdStr string, err error) { assert.EqualError(t, err, "No editor defined in config file, $GIT_EDITOR, $VISUAL, $EDITOR, or git config") }, }, { filename: "test", configEditCommand: "nano", configEditCommandTemplate: "{{editor}} {{filename}}", runner: oscommands.NewFakeRunner(t), getenv: func(env string) string { return "" }, gitConfigMockResponses: nil, test: func(cmdStr string, err error) { assert.NoError(t, err) assert.Equal(t, `nano "test"`, cmdStr) }, }, { filename: "test", configEditCommand: "", configEditCommandTemplate: "{{editor}} {{filename}}", runner: oscommands.NewFakeRunner(t), getenv: func(env string) string { return "" }, gitConfigMockResponses: map[string]string{"core.editor": "nano"}, test: func(cmdStr string, err error) { assert.NoError(t, err) assert.Equal(t, `nano "test"`, cmdStr) }, }, { filename: "test", configEditCommand: "", configEditCommandTemplate: "{{editor}} {{filename}}", runner: oscommands.NewFakeRunner(t), getenv: func(env string) string { if env == "VISUAL" { return "nano" } return "" }, gitConfigMockResponses: nil, test: func(cmdStr string, err error) { assert.NoError(t, err) assert.Equal(t, `nano "test"`, cmdStr) }, }, { filename: "test", configEditCommand: "", configEditCommandTemplate: "{{editor}} {{filename}}", runner: oscommands.NewFakeRunner(t), getenv: func(env string) string { if env == "EDITOR" { return "emacs" } return "" }, gitConfigMockResponses: nil, test: func(cmdStr string, err error) { assert.NoError(t, err) assert.Equal(t, `emacs "test"`, cmdStr) }, }, { filename: "test", configEditCommand: "", configEditCommandTemplate: "{{editor}} {{filename}}", runner: oscommands.NewFakeRunner(t). ExpectArgs([]string{"which", "vi"}, "/usr/bin/vi", nil), getenv: func(env string) string { return "" }, gitConfigMockResponses: nil, test: func(cmdStr string, err error) { assert.NoError(t, err) assert.Equal(t, `vi "test"`, cmdStr) }, }, { filename: "file/with space", configEditCommand: "", configEditCommandTemplate: "{{editor}} {{filename}}", runner: oscommands.NewFakeRunner(t). ExpectArgs([]string{"which", "vi"}, "/usr/bin/vi", nil), getenv: func(env string) string { return "" }, gitConfigMockResponses: nil, test: func(cmdStr string, err error) { assert.NoError(t, err) assert.Equal(t, `vi "file/with space"`, cmdStr) }, }, { filename: "open file/at line", configEditCommand: "vim", configEditCommandTemplate: "{{editor}} +{{line}} {{filename}}", runner: oscommands.NewFakeRunner(t), getenv: func(env string) string { return "" }, gitConfigMockResponses: nil, test: func(cmdStr string, err error) { assert.NoError(t, err) assert.Equal(t, `vim +1 "open file/at line"`, cmdStr) }, }, { filename: "default edit command template", configEditCommand: "vim", configEditCommandTemplate: "", runner: oscommands.NewFakeRunner(t), getenv: func(env string) string { return "" }, gitConfigMockResponses: nil, test: func(cmdStr string, err error) { assert.NoError(t, err) assert.Equal(t, `vim +1 -- "default edit command template"`, cmdStr) }, }, } for _, s := range scenarios { userConfig := config.GetDefaultConfig() userConfig.OS.EditCommand = s.configEditCommand userConfig.OS.EditCommandTemplate = s.configEditCommandTemplate instance := buildFileCommands(commonDeps{ runner: s.runner, userConfig: userConfig, gitConfig: git_config.NewFakeGitConfig(s.gitConfigMockResponses), getenv: s.getenv, }) s.test(instance.GetEditCmdStrLegacy(s.filename, 1)) s.runner.CheckForMissingCalls() } } func TestEditFilesCmd(t *testing.T) { type scenario struct { filenames []string osConfig config.OSConfig expectedCmdStr string suspend bool } scenarios := []scenario{ { filenames: []string{"test"}, osConfig: config.OSConfig{}, expectedCmdStr: `vim -- "test"`, suspend: true, }, { filenames: []string{"test"}, osConfig: config.OSConfig{ Edit: "nano {{filename}}", }, expectedCmdStr: `nano "test"`, suspend: true, }, { filenames: []string{"file/with space"}, osConfig: config.OSConfig{ EditPreset: "sublime", }, expectedCmdStr: `subl -- "file/with space"`, suspend: false, }, { filenames: []string{"multiple", "files"}, osConfig: config.OSConfig{ EditPreset: "sublime", }, expectedCmdStr: `subl -- "multiple" "files"`, suspend: false, }, } for _, s := range scenarios { userConfig := config.GetDefaultConfig() userConfig.OS = s.osConfig instance := buildFileCommands(commonDeps{ userConfig: userConfig, }) cmdStr, suspend := instance.GetEditCmdStr(s.filenames) assert.Equal(t, s.expectedCmdStr, cmdStr) assert.Equal(t, s.suspend, suspend) } } func TestEditFileAtLineCmd(t *testing.T) { type scenario struct { filename string lineNumber int osConfig config.OSConfig expectedCmdStr string suspend bool } scenarios := []scenario{ { filename: "test", lineNumber: 42, osConfig: config.OSConfig{}, expectedCmdStr: `vim +42 -- "test"`, suspend: true, }, { filename: "test", lineNumber: 35, osConfig: config.OSConfig{ EditAtLine: "nano +{{line}} {{filename}}", }, expectedCmdStr: `nano +35 "test"`, suspend: true, }, { filename: "file/with space", lineNumber: 12, osConfig: config.OSConfig{ EditPreset: "sublime", }, expectedCmdStr: `subl -- "file/with space":12`, suspend: false, }, } for _, s := range scenarios { userConfig := config.GetDefaultConfig() userConfig.OS = s.osConfig instance := buildFileCommands(commonDeps{ userConfig: userConfig, }) cmdStr, suspend := instance.GetEditAtLineCmdStr(s.filename, s.lineNumber) assert.Equal(t, s.expectedCmdStr, cmdStr) assert.Equal(t, s.suspend, suspend) } } func TestEditFileAtLineAndWaitCmd(t *testing.T) { type scenario struct { filename string lineNumber int osConfig config.OSConfig expectedCmdStr string } scenarios := []scenario{ { filename: "test", lineNumber: 42, osConfig: config.OSConfig{}, expectedCmdStr: `vim +42 -- "test"`, }, { filename: "file/with space", lineNumber: 12, osConfig: config.OSConfig{ EditPreset: "sublime", }, expectedCmdStr: `subl --wait -- "file/with space":12`, }, } for _, s := range scenarios { userConfig := config.GetDefaultConfig() userConfig.OS = s.osConfig instance := buildFileCommands(commonDeps{ userConfig: userConfig, }) cmdStr := instance.GetEditAtLineAndWaitCmdStr(s.filename, s.lineNumber) assert.Equal(t, s.expectedCmdStr, cmdStr) } } func TestGuessDefaultEditor(t *testing.T) { type scenario struct { gitConfigMockResponses map[string]string getenv func(string) string expectedResult string } scenarios := []scenario{ { gitConfigMockResponses: nil, getenv: func(env string) string { return "" }, expectedResult: "", }, { gitConfigMockResponses: map[string]string{"core.editor": "nano"}, getenv: func(env string) string { return "" }, expectedResult: "nano", }, { gitConfigMockResponses: map[string]string{"core.editor": "code -w"}, getenv: func(env string) string { return "" }, expectedResult: "code", }, { gitConfigMockResponses: nil, getenv: func(env string) string { if env == "VISUAL" { return "emacs" } return "" }, expectedResult: "emacs", }, { gitConfigMockResponses: nil, getenv: func(env string) string { if env == "EDITOR" { return "bbedit -w" } return "" }, expectedResult: "bbedit", }, } for _, s := range scenarios { instance := buildFileCommands(commonDeps{ gitConfig: git_config.NewFakeGitConfig(s.gitConfigMockResponses), getenv: s.getenv, }) assert.Equal(t, s.expectedResult, instance.guessDefaultEditor()) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/flow.go000066400000000000000000000027021500612110400222550ustar00rootroot00000000000000package git_commands import ( "regexp" "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) type FlowCommands struct { *GitCommon } func NewFlowCommands( gitCommon *GitCommon, ) *FlowCommands { return &FlowCommands{ GitCommon: gitCommon, } } func (self *FlowCommands) GitFlowEnabled() bool { return self.config.GetGitFlowPrefixes() != "" } func (self *FlowCommands) FinishCmdObj(branchName string) (oscommands.ICmdObj, error) { prefixes := self.config.GetGitFlowPrefixes() // need to find out what kind of branch this is prefix := strings.SplitAfterN(branchName, "/", 2)[0] suffix := strings.Replace(branchName, prefix, "", 1) branchType := "" for _, line := range strings.Split(strings.TrimSpace(prefixes), "\n") { if strings.HasPrefix(line, "gitflow.prefix.") && strings.HasSuffix(line, prefix) { regex := regexp.MustCompile("gitflow.prefix.([^ ]*) .*") matches := regex.FindAllStringSubmatch(line, 1) if len(matches) > 0 && len(matches[0]) > 1 { branchType = matches[0][1] break } } } if branchType == "" { return nil, errors.New(self.Tr.NotAGitFlowBranch) } cmdArgs := NewGitCmd("flow").Arg(branchType, "finish", suffix).ToArgv() return self.cmd.New(cmdArgs), nil } func (self *FlowCommands) StartCmdObj(branchType string, name string) oscommands.ICmdObj { cmdArgs := NewGitCmd("flow").Arg(branchType, "start", name).ToArgv() return self.cmd.New(cmdArgs) } lazygit-0.50.0+ds1/pkg/commands/git_commands/flow_test.go000066400000000000000000000042721500612110400233200ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/stretchr/testify/assert" ) func TestStartCmdObj(t *testing.T) { scenarios := []struct { testName string branchType string name string expected []string }{ { testName: "basic", branchType: "feature", name: "test", expected: []string{"git", "flow", "feature", "start", "test"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildFlowCommands(commonDeps{}) assert.Equal(t, instance.StartCmdObj(s.branchType, s.name).Args(), s.expected, ) }) } } func TestFinishCmdObj(t *testing.T) { scenarios := []struct { testName string branchName string expected []string expectedError string gitConfigMockResponses map[string]string }{ { testName: "not a git flow branch", branchName: "mybranch", expected: nil, expectedError: "This does not seem to be a git flow branch", gitConfigMockResponses: nil, }, { testName: "feature branch without config", branchName: "feature/mybranch", expected: nil, expectedError: "This does not seem to be a git flow branch", gitConfigMockResponses: nil, }, { testName: "feature branch with config", branchName: "feature/mybranch", expected: []string{"git", "flow", "feature", "finish", "mybranch"}, expectedError: "", gitConfigMockResponses: map[string]string{ "--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/", }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildFlowCommands(commonDeps{ gitConfig: git_config.NewFakeGitConfig(s.gitConfigMockResponses), }) cmd, err := instance.FinishCmdObj(s.branchName) if s.expectedError != "" { if err == nil { t.Errorf("Expected error, got nil") } else { assert.Equal(t, err.Error(), s.expectedError) } } else { assert.NoError(t, err) assert.Equal(t, cmd.Args(), s.expected) } }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/git_command_builder.go000066400000000000000000000050221500612110400252730ustar00rootroot00000000000000package git_commands import ( "strings" ) // convenience struct for building git commands. Especially useful when // including conditional args type GitCommandBuilder struct { // command string args []string } func NewGitCmd(command string) *GitCommandBuilder { return &GitCommandBuilder{args: []string{command}} } func (self *GitCommandBuilder) Arg(args ...string) *GitCommandBuilder { self.args = append(self.args, args...) return self } func (self *GitCommandBuilder) ArgIf(condition bool, ifTrue ...string) *GitCommandBuilder { if condition { self.Arg(ifTrue...) } return self } func (self *GitCommandBuilder) ArgIfElse(condition bool, ifTrue string, ifFalse string) *GitCommandBuilder { if condition { return self.Arg(ifTrue) } else { return self.Arg(ifFalse) } } func (self *GitCommandBuilder) Config(value string) *GitCommandBuilder { // config settings come before the command self.args = append([]string{"-c", value}, self.args...) return self } func (self *GitCommandBuilder) ConfigIf(condition bool, ifTrue string) *GitCommandBuilder { if condition { self.Config(ifTrue) } return self } // the -C arg will make git do a `cd` to the directory before doing anything else func (self *GitCommandBuilder) Dir(path string) *GitCommandBuilder { // repo path comes before the command self.args = append([]string{"-C", path}, self.args...) return self } func (self *GitCommandBuilder) DirIf(condition bool, path string) *GitCommandBuilder { if condition { return self.Dir(path) } return self } // Note, you may prefer to use the Dir method instead of this one func (self *GitCommandBuilder) Worktree(path string) *GitCommandBuilder { // worktree arg comes before the command self.args = append([]string{"--work-tree", path}, self.args...) return self } func (self *GitCommandBuilder) WorktreePathIf(condition bool, path string) *GitCommandBuilder { if condition { return self.Worktree(path) } return self } // Note, you may prefer to use the Dir method instead of this one func (self *GitCommandBuilder) GitDir(path string) *GitCommandBuilder { // git dir arg comes before the command self.args = append([]string{"--git-dir", path}, self.args...) return self } func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommandBuilder { if condition { return self.GitDir(path) } return self } func (self *GitCommandBuilder) ToArgv() []string { return append([]string{"git"}, self.args...) } func (self *GitCommandBuilder) ToString() string { return strings.Join(self.ToArgv(), " ") } lazygit-0.50.0+ds1/pkg/commands/git_commands/git_command_builder_test.go000066400000000000000000000026051500612110400263360ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/stretchr/testify/assert" ) func TestGitCommandBuilder(t *testing.T) { scenarios := []struct { input []string expected []string }{ { input: NewGitCmd("push"). Arg("--force-with-lease"). Arg("--set-upstream"). Arg("origin"). Arg("master"). ToArgv(), expected: []string{"git", "push", "--force-with-lease", "--set-upstream", "origin", "master"}, }, { input: NewGitCmd("push").ArgIf(true, "--test").ToArgv(), expected: []string{"git", "push", "--test"}, }, { input: NewGitCmd("push").ArgIf(false, "--test").ToArgv(), expected: []string{"git", "push"}, }, { input: NewGitCmd("push").ArgIfElse(true, "-b", "-a").ToArgv(), expected: []string{"git", "push", "-b"}, }, { input: NewGitCmd("push").ArgIfElse(false, "-a", "-b").ToArgv(), expected: []string{"git", "push", "-b"}, }, { input: NewGitCmd("push").Arg("-a", "-b").ToArgv(), expected: []string{"git", "push", "-a", "-b"}, }, { input: NewGitCmd("push").Config("user.name=foo").Config("user.email=bar").ToArgv(), expected: []string{"git", "-c", "user.email=bar", "-c", "user.name=foo", "push"}, }, { input: NewGitCmd("push").Dir("a/b/c").ToArgv(), expected: []string{"git", "-C", "a/b/c", "push"}, }, } for _, s := range scenarios { assert.Equal(t, s.input, s.expected) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/main_branches.go000066400000000000000000000075341500612110400241070ustar00rootroot00000000000000package git_commands import ( "strings" "sync" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" ) type MainBranches struct { c *common.Common // Which of the configured main branches actually exist in the repository. Full // ref names, and it could be either "refs/heads/..." or "refs/remotes/origin/..." // depending on which one exists for a given bare name. existingMainBranches []string previousMainBranches []string cmd oscommands.ICmdObjBuilder mutex *deadlock.Mutex } func NewMainBranches( cmn *common.Common, cmd oscommands.ICmdObjBuilder, ) *MainBranches { return &MainBranches{ c: cmn, existingMainBranches: nil, cmd: cmd, mutex: &deadlock.Mutex{}, } } // Get the list of main branches that exist in the repository. This is a list of // full ref names. func (self *MainBranches) Get() []string { self.mutex.Lock() defer self.mutex.Unlock() configuredMainBranches := self.c.UserConfig().Git.MainBranches if self.existingMainBranches == nil || !utils.EqualSlices(self.previousMainBranches, configuredMainBranches) { self.existingMainBranches = self.determineMainBranches(configuredMainBranches) self.previousMainBranches = configuredMainBranches } return self.existingMainBranches } // Return the merge base of the given refName with the closest main branch. func (self *MainBranches) GetMergeBase(refName string) string { mainBranches := self.Get() if len(mainBranches) == 0 { return "" } // We pass all existing main branches to the merge-base call; git will // return the base commit for the closest one. // We ignore errors from this call, since we can't distinguish whether the // error is because one of the main branches has been deleted since the last // call to determineMainBranches, or because the refName has no common // history with any of the main branches. Since the former should happen // very rarely, users must quit and restart lazygit to fix it; the latter is // also not very common, but can totally happen and is not an error. output, _ := self.cmd.New( NewGitCmd("merge-base").Arg(refName).Arg(mainBranches...). ToArgv(), ).DontLog().RunWithOutput() return ignoringWarnings(output) } func (self *MainBranches) determineMainBranches(configuredMainBranches []string) []string { var existingBranches []string var wg sync.WaitGroup existingBranches = make([]string, len(configuredMainBranches)) for i, branchName := range configuredMainBranches { wg.Add(1) go utils.Safe(func() { defer wg.Done() // Try to determine upstream of local main branch if ref, err := self.cmd.New( NewGitCmd("rev-parse").Arg("--symbolic-full-name", branchName+"@{u}").ToArgv(), ).DontLog().RunWithOutput(); err == nil { existingBranches[i] = strings.TrimSpace(ref) return } // If this failed, a local branch for this main branch doesn't exist or it // has no upstream configured. Try looking for one in the "origin" remote. ref := "refs/remotes/origin/" + branchName if err := self.cmd.New( NewGitCmd("rev-parse").Arg("--verify", "--quiet", ref).ToArgv(), ).DontLog().Run(); err == nil { existingBranches[i] = ref return } // If this failed as well, try if we have the main branch as a local // branch. This covers the case where somebody is using git locally // for something, but never pushing anywhere. ref = "refs/heads/" + branchName if err := self.cmd.New( NewGitCmd("rev-parse").Arg("--verify", "--quiet", ref).ToArgv(), ).DontLog().Run(); err == nil { existingBranches[i] = ref } }) } wg.Wait() existingBranches = lo.Filter(existingBranches, func(branch string, _ int) bool { return branch != "" }) return existingBranches } lazygit-0.50.0+ds1/pkg/commands/git_commands/patch.go000066400000000000000000000215621500612110400224120ustar00rootroot00000000000000package git_commands import ( "path/filepath" "time" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/stefanhaller/git-todo-parser/todo" ) type PatchCommands struct { *GitCommon rebase *RebaseCommands commit *CommitCommands status *StatusCommands stash *StashCommands PatchBuilder *patch.PatchBuilder } func NewPatchCommands( gitCommon *GitCommon, rebase *RebaseCommands, commit *CommitCommands, status *StatusCommands, stash *StashCommands, patchBuilder *patch.PatchBuilder, ) *PatchCommands { return &PatchCommands{ GitCommon: gitCommon, rebase: rebase, commit: commit, status: status, stash: stash, PatchBuilder: patchBuilder, } } type ApplyPatchOpts struct { ThreeWay bool Cached bool Index bool Reverse bool } func (self *PatchCommands) ApplyCustomPatch(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) error { patch := self.PatchBuilder.PatchToApply(reverse, turnAddedFilesIntoDiffAgainstEmptyFile) return self.ApplyPatch(patch, ApplyPatchOpts{ Index: true, ThreeWay: true, Reverse: reverse, }) } func (self *PatchCommands) ApplyPatch(patch string, opts ApplyPatchOpts) error { filepath, err := self.SaveTemporaryPatch(patch) if err != nil { return err } return self.applyPatchFile(filepath, opts) } func (self *PatchCommands) applyPatchFile(filepath string, opts ApplyPatchOpts) error { cmdArgs := NewGitCmd("apply"). ArgIf(opts.ThreeWay, "--3way"). ArgIf(opts.Cached, "--cached"). ArgIf(opts.Index, "--index"). ArgIf(opts.Reverse, "--reverse"). Arg(filepath). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *PatchCommands) SaveTemporaryPatch(patch string) (string, error) { filepath := filepath.Join(self.os.GetTempDir(), self.repoPaths.RepoName(), time.Now().Format("Jan _2 15.04.05.000000000")+".patch") self.Log.Infof("saving temporary patch to %s", filepath) if err := self.os.CreateFileWithContent(filepath, patch); err != nil { return "", err } return filepath, nil } // DeletePatchesFromCommit applies a patch in reverse for a commit func (self *PatchCommands) DeletePatchesFromCommit(commits []*models.Commit, commitIndex int) error { if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIndex, false); err != nil { return err } // apply each patch in reverse if err := self.ApplyCustomPatch(true, true); err != nil { _ = self.rebase.AbortRebase() return err } // time to amend the selected commit if err := self.commit.AmendHead(); err != nil { return err } self.rebase.onSuccessfulContinue = func() error { self.PatchBuilder.Reset() return nil } // continue return self.rebase.ContinueRebase() } func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, sourceCommitIdx int, destinationCommitIdx int) error { if sourceCommitIdx < destinationCommitIdx { // Passing true for keepCommitsThatBecomeEmpty: if the moved-from // commit becomes empty, we want to keep it, mainly for consistency with // moving the patch to a *later* commit, which behaves the same. if err := self.rebase.BeginInteractiveRebaseForCommit(commits, destinationCommitIdx, true); err != nil { return err } // apply each patch forward if err := self.ApplyCustomPatch(false, false); err != nil { // Don't abort the rebase here; this might cause conflicts, so give // the user a chance to resolve them return err } // amend the destination commit if err := self.commit.AmendHead(); err != nil { return err } self.rebase.onSuccessfulContinue = func() error { self.PatchBuilder.Reset() return nil } // continue return self.rebase.ContinueRebase() } if len(commits)-1 < sourceCommitIdx { return errors.New("index outside of range of commits") } // we can make this GPG thing possible it just means we need to do this in two parts: // one where we handle the possibility of a credential request, and the other // where we continue the rebase if self.config.NeedsGpgSubprocessForCommit() { return errors.New(self.Tr.DisabledForGPG) } baseIndex := sourceCommitIdx + 1 changes := []daemon.ChangeTodoAction{ {Hash: commits[sourceCommitIdx].Hash(), NewAction: todo.Edit}, {Hash: commits[destinationCommitIdx].Hash(), NewAction: todo.Edit}, } self.os.LogCommand(logTodoChanges(changes), false) err := self.rebase.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: commits[baseIndex].Hash(), overrideEditor: true, instruction: daemon.NewChangeTodoActionsInstruction(changes), }).Run() if err != nil { return err } // apply each patch in reverse if err := self.ApplyCustomPatch(true, true); err != nil { _ = self.rebase.AbortRebase() return err } // amend the source commit if err := self.commit.AmendHead(); err != nil { return err } patch, err := self.diffHeadAgainstCommit(commits[sourceCommitIdx]) if err != nil { _ = self.rebase.AbortRebase() return err } if self.rebase.onSuccessfulContinue != nil { return errors.New("You are midway through another rebase operation. Please abort to start again") } self.rebase.onSuccessfulContinue = func() error { // now we should be up to the destination, so let's apply forward these patches to that. // ideally we would ensure we're on the right commit but I'm not sure if that check is necessary if err := self.ApplyPatch(patch, ApplyPatchOpts{Index: true, ThreeWay: true}); err != nil { // Don't abort the rebase here; this might cause conflicts, so give // the user a chance to resolve them return err } // amend the destination commit if err := self.commit.AmendHead(); err != nil { return err } self.rebase.onSuccessfulContinue = func() error { self.PatchBuilder.Reset() return nil } return self.rebase.ContinueRebase() } return self.rebase.ContinueRebase() } func (self *PatchCommands) MovePatchIntoIndex(commits []*models.Commit, commitIdx int, stash bool) error { if stash { if err := self.stash.Push(self.Tr.StashPrefix + commits[commitIdx].Hash()); err != nil { return err } } if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIdx, false); err != nil { return err } if err := self.ApplyCustomPatch(true, true); err != nil { if self.status.WorkingTreeState().Rebasing { _ = self.rebase.AbortRebase() } return err } // amend the commit if err := self.commit.AmendHead(); err != nil { return err } patch, err := self.diffHeadAgainstCommit(commits[commitIdx]) if err != nil { _ = self.rebase.AbortRebase() return err } if self.rebase.onSuccessfulContinue != nil { return errors.New("You are midway through another rebase operation. Please abort to start again") } self.rebase.onSuccessfulContinue = func() error { // add patches to index if err := self.ApplyPatch(patch, ApplyPatchOpts{Index: true, ThreeWay: true}); err != nil { if self.status.WorkingTreeState().Rebasing { _ = self.rebase.AbortRebase() } return err } if stash { if err := self.stash.Apply(0); err != nil { return err } } self.PatchBuilder.Reset() return nil } return self.rebase.ContinueRebase() } func (self *PatchCommands) PullPatchIntoNewCommit( commits []*models.Commit, commitIdx int, commitSummary string, commitDescription string, ) error { if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIdx, false); err != nil { return err } if err := self.ApplyCustomPatch(true, true); err != nil { _ = self.rebase.AbortRebase() return err } // amend the commit if err := self.commit.AmendHead(); err != nil { return err } patch, err := self.diffHeadAgainstCommit(commits[commitIdx]) if err != nil { _ = self.rebase.AbortRebase() return err } if err := self.ApplyPatch(patch, ApplyPatchOpts{Index: true, ThreeWay: true}); err != nil { _ = self.rebase.AbortRebase() return err } if err := self.commit.CommitCmdObj(commitSummary, commitDescription, false).Run(); err != nil { return err } if self.rebase.onSuccessfulContinue != nil { return errors.New("You are midway through another rebase operation. Please abort to start again") } self.PatchBuilder.Reset() return self.rebase.ContinueRebase() } // We have just applied a patch in reverse to discard it from a commit; if we // now try to apply the patch again to move it to a later commit, or to the // index, then this would conflict "with itself" in case the patch contained // only some lines of a range of adjacent added lines. To solve this, we // get the diff of HEAD and the original commit and then apply that. func (self *PatchCommands) diffHeadAgainstCommit(commit *models.Commit) (string, error) { cmdArgs := NewGitCmd("diff"). Config("diff.noprefix=false"). Arg("--no-ext-diff"). Arg("HEAD.." + commit.Hash()). ToArgv() return self.cmd.New(cmdArgs).RunWithOutput() } lazygit-0.50.0+ds1/pkg/commands/git_commands/rebase.go000066400000000000000000000441741500612110400225600ustar00rootroot00000000000000package git_commands import ( "fmt" "path/filepath" "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) type RebaseCommands struct { *GitCommon commit *CommitCommands workingTree *WorkingTreeCommands onSuccessfulContinue func() error } func NewRebaseCommands( gitCommon *GitCommon, commitCommands *CommitCommands, workingTreeCommands *WorkingTreeCommands, ) *RebaseCommands { return &RebaseCommands{ GitCommon: gitCommon, commit: commitCommands, workingTree: workingTreeCommands, } } func (self *RebaseCommands) RewordCommit(commits []*models.Commit, index int, summary string, description string) error { if self.config.NeedsGpgSubprocessForCommit() { return errors.New(self.Tr.DisabledForGPG) } err := self.BeginInteractiveRebaseForCommit(commits, index, false) if err != nil { return err } // now the selected commit should be our head so we'll amend it with the new message err = self.commit.RewordLastCommit(summary, description).Run() if err != nil { return err } return self.ContinueRebase() } func (self *RebaseCommands) RewordCommitInEditor(commits []*models.Commit, index int) (oscommands.ICmdObj, error) { changes := []daemon.ChangeTodoAction{{ Hash: commits[index].Hash(), NewAction: todo.Reword, }} self.os.LogCommand(logTodoChanges(changes), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: getBaseHashOrRoot(commits, index+1), instruction: daemon.NewChangeTodoActionsInstruction(changes), }), nil } func (self *RebaseCommands) ResetCommitAuthor(commits []*models.Commit, start, end int) error { return self.GenericAmend(commits, start, end, func(_ *models.Commit) error { return self.commit.ResetAuthor() }) } func (self *RebaseCommands) SetCommitAuthor(commits []*models.Commit, start, end int, value string) error { return self.GenericAmend(commits, start, end, func(_ *models.Commit) error { return self.commit.SetAuthor(value) }) } func (self *RebaseCommands) AddCommitCoAuthor(commits []*models.Commit, start, end int, value string) error { return self.GenericAmend(commits, start, end, func(commit *models.Commit) error { return self.commit.AddCoAuthor(commit.Hash(), value) }) } func (self *RebaseCommands) GenericAmend(commits []*models.Commit, start, end int, f func(commit *models.Commit) error) error { if start == end && models.IsHeadCommit(commits, start) { // we've selected the top commit so no rebase is required return f(commits[start]) } err := self.BeginInteractiveRebaseForCommitRange(commits, start, end, false) if err != nil { return err } for commitIndex := end; commitIndex >= start; commitIndex-- { err = f(commits[commitIndex]) if err != nil { return err } if err := self.ContinueRebase(); err != nil { return err } } return nil } func (self *RebaseCommands) MoveCommitsDown(commits []*models.Commit, startIdx int, endIdx int) error { baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+2) hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string { return commit.Hash() }) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: baseHashOrRoot, instruction: daemon.NewMoveTodosDownInstruction(hashes), overrideEditor: true, }).Run() } func (self *RebaseCommands) MoveCommitsUp(commits []*models.Commit, startIdx int, endIdx int) error { baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+1) hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string { return commit.Hash() }) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: baseHashOrRoot, instruction: daemon.NewMoveTodosUpInstruction(hashes), overrideEditor: true, }).Run() } func (self *RebaseCommands) InteractiveRebase(commits []*models.Commit, startIdx int, endIdx int, action todo.TodoCommand) error { baseIndex := endIdx + 1 if action == todo.Squash || action == todo.Fixup { baseIndex++ } baseHashOrRoot := getBaseHashOrRoot(commits, baseIndex) changes := lo.FilterMap(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) (daemon.ChangeTodoAction, bool) { return daemon.ChangeTodoAction{ Hash: commit.Hash(), NewAction: action, }, !commit.IsMerge() }) self.os.LogCommand(logTodoChanges(changes), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: baseHashOrRoot, overrideEditor: true, instruction: daemon.NewChangeTodoActionsInstruction(changes), }).Run() } func (self *RebaseCommands) EditRebase(branchRef string) error { msg := utils.ResolvePlaceholderString( self.Tr.Log.EditRebase, map[string]string{ "ref": branchRef, }, ) self.os.LogCommand(msg, false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: branchRef, instruction: daemon.NewInsertBreakInstruction(), }).Run() } func (self *RebaseCommands) EditRebaseFromBaseCommit(targetBranchName string, baseCommit string) error { msg := utils.ResolvePlaceholderString( self.Tr.Log.EditRebaseFromBaseCommit, map[string]string{ "baseCommit": baseCommit, "targetBranchName": targetBranchName, }, ) self.os.LogCommand(msg, false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: baseCommit, onto: targetBranchName, instruction: daemon.NewInsertBreakInstruction(), }).Run() } func logTodoChanges(changes []daemon.ChangeTodoAction) string { changeTodoStr := strings.Join(lo.Map(changes, func(c daemon.ChangeTodoAction, _ int) string { return fmt.Sprintf("%s:%s", c.Hash, c.NewAction) }), "\n") return fmt.Sprintf("Changing TODO actions:\n%s", changeTodoStr) } type PrepareInteractiveRebaseCommandOpts struct { baseHashOrRoot string onto string instruction daemon.Instruction overrideEditor bool keepCommitsThatBecomeEmpty bool } // PrepareInteractiveRebaseCommand returns the cmd for an interactive rebase // we tell git to run lazygit to edit the todo list, and we pass the client // lazygit instructions what to do with the todo file func (self *RebaseCommands) PrepareInteractiveRebaseCommand(opts PrepareInteractiveRebaseCommandOpts) oscommands.ICmdObj { ex := oscommands.GetLazygitPath() cmdArgs := NewGitCmd("rebase"). Arg("--interactive"). Arg("--autostash"). Arg("--keep-empty"). ArgIf(opts.keepCommitsThatBecomeEmpty && self.version.IsAtLeast(2, 26, 0), "--empty=keep"). Arg("--no-autosquash"). Arg("--rebase-merges"). ArgIf(opts.onto != "", "--onto", opts.onto). Arg(opts.baseHashOrRoot). ToArgv() debug := "FALSE" if self.Debug { debug = "TRUE" } self.Log.WithField("command", cmdArgs).Debug("RunCommand") cmdObj := self.cmd.New(cmdArgs) gitSequenceEditor := ex if opts.instruction != nil { cmdObj.AddEnvVars(daemon.ToEnvVars(opts.instruction)...) } else { cmdObj.AddEnvVars(daemon.ToEnvVars(daemon.NewRemoveUpdateRefsForCopiedBranchInstruction())...) } cmdObj.AddEnvVars( "DEBUG="+debug, "LANG=en_US.UTF-8", // Force using EN as language "LC_ALL=en_US.UTF-8", // Force using EN as language "GIT_SEQUENCE_EDITOR="+gitSequenceEditor, ) if opts.overrideEditor { cmdObj.AddEnvVars("GIT_EDITOR=" + ex) } return cmdObj } // GitRebaseEditTodo runs "git rebase --edit-todo", saving the given todosFileContent to the file func (self *RebaseCommands) GitRebaseEditTodo(todosFileContent []byte) error { ex := oscommands.GetLazygitPath() cmdArgs := NewGitCmd("rebase"). Arg("--edit-todo"). ToArgv() debug := "FALSE" if self.Debug { debug = "TRUE" } self.Log.WithField("command", cmdArgs).Debug("RunCommand") cmdObj := self.cmd.New(cmdArgs) cmdObj.AddEnvVars(daemon.ToEnvVars(daemon.NewWriteRebaseTodoInstruction(todosFileContent))...) cmdObj.AddEnvVars( "DEBUG="+debug, "LANG=en_US.UTF-8", // Force using EN as language "LC_ALL=en_US.UTF-8", // Force using EN as language "GIT_EDITOR="+ex, "GIT_SEQUENCE_EDITOR="+ex, ) return cmdObj.Run() } func (self *RebaseCommands) getHashOfLastCommitMade() (string, error) { cmdArgs := NewGitCmd("rev-parse").Arg("--verify", "HEAD").ToArgv() return self.cmd.New(cmdArgs).RunWithOutput() } // AmendTo amends the given commit with whatever files are staged func (self *RebaseCommands) AmendTo(commits []*models.Commit, commitIndex int) error { commit := commits[commitIndex] if err := self.commit.CreateFixupCommit(commit.Hash()); err != nil { return err } fixupHash, err := self.getHashOfLastCommitMade() if err != nil { return err } return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: getBaseHashOrRoot(commits, commitIndex+1), overrideEditor: true, instruction: daemon.NewMoveFixupCommitDownInstruction(commit.Hash(), fixupHash, true), }).Run() } func (self *RebaseCommands) MoveFixupCommitDown(commits []*models.Commit, targetCommitIndex int) error { fixupHash, err := self.getHashOfLastCommitMade() if err != nil { return err } return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: getBaseHashOrRoot(commits, targetCommitIndex+1), overrideEditor: true, instruction: daemon.NewMoveFixupCommitDownInstruction(commits[targetCommitIndex].Hash(), fixupHash, false), }).Run() } func todoFromCommit(commit *models.Commit) utils.Todo { if commit.Action == todo.UpdateRef { return utils.Todo{Ref: commit.Name} } else { return utils.Todo{Hash: commit.Hash()} } } // Sets the action for the given commits in the git-rebase-todo file func (self *RebaseCommands) EditRebaseTodo(commits []*models.Commit, action todo.TodoCommand) error { commitsWithAction := lo.Map(commits, func(commit *models.Commit, _ int) utils.TodoChange { return utils.TodoChange{ Hash: commit.Hash(), NewAction: action, } }) return utils.EditRebaseTodo( filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"), commitsWithAction, self.config.GetCoreCommentChar(), ) } func (self *RebaseCommands) DeleteUpdateRefTodos(commits []*models.Commit) error { todosToDelete := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo { return todoFromCommit(commit) }) todosFileContent, err := utils.DeleteTodos( filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"), todosToDelete, self.config.GetCoreCommentChar(), ) if err != nil { return err } return self.GitRebaseEditTodo(todosFileContent) } func (self *RebaseCommands) MoveTodosDown(commits []*models.Commit) error { fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo") todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo { return todoFromCommit(commit) }) return utils.MoveTodosDown(fileName, todosToMove, true, self.config.GetCoreCommentChar()) } func (self *RebaseCommands) MoveTodosUp(commits []*models.Commit) error { fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo") todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo { return todoFromCommit(commit) }) return utils.MoveTodosUp(fileName, todosToMove, true, self.config.GetCoreCommentChar()) } // SquashAllAboveFixupCommits squashes all fixup! commits above the given one func (self *RebaseCommands) SquashAllAboveFixupCommits(commit *models.Commit) error { hashOrRoot := commit.Hash() + "^" if commit.IsFirstCommit() { hashOrRoot = "--root" } cmdArgs := NewGitCmd("rebase"). Arg("--interactive", "--rebase-merges", "--autostash", "--autosquash", hashOrRoot). ToArgv() return self.runSkipEditorCommand(self.cmd.New(cmdArgs)) } // BeginInteractiveRebaseForCommit starts an interactive rebase to edit the current // commit and pick all others. After this you'll want to call `self.ContinueRebase() func (self *RebaseCommands) BeginInteractiveRebaseForCommit( commits []*models.Commit, commitIndex int, keepCommitsThatBecomeEmpty bool, ) error { return self.BeginInteractiveRebaseForCommitRange(commits, commitIndex, commitIndex, keepCommitsThatBecomeEmpty) } func (self *RebaseCommands) BeginInteractiveRebaseForCommitRange( commits []*models.Commit, start, end int, keepCommitsThatBecomeEmpty bool, ) error { if len(commits)-1 < end { return errors.New("index outside of range of commits") } // we can make this GPG thing possible it just means we need to do this in two parts: // one where we handle the possibility of a credential request, and the other // where we continue the rebase if self.config.NeedsGpgSubprocessForCommit() { return errors.New(self.Tr.DisabledForGPG) } changes := make([]daemon.ChangeTodoAction, 0, end-start) for commitIndex := end; commitIndex >= start; commitIndex-- { changes = append(changes, daemon.ChangeTodoAction{ Hash: commits[commitIndex].Hash(), NewAction: todo.Edit, }) } self.os.LogCommand(logTodoChanges(changes), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: getBaseHashOrRoot(commits, end+1), overrideEditor: true, keepCommitsThatBecomeEmpty: keepCommitsThatBecomeEmpty, instruction: daemon.NewChangeTodoActionsInstruction(changes), }).Run() } // RebaseBranch interactive rebases onto a branch func (self *RebaseCommands) RebaseBranch(branchName string) error { return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{baseHashOrRoot: branchName}).Run() } func (self *RebaseCommands) RebaseBranchFromBaseCommit(targetBranchName string, baseCommit string) error { return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: baseCommit, onto: targetBranchName, }).Run() } func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(commandType string, command string) oscommands.ICmdObj { cmdArgs := NewGitCmd(commandType).Arg("--" + command).ToArgv() return self.cmd.New(cmdArgs) } func (self *RebaseCommands) ContinueRebase() error { return self.GenericMergeOrRebaseAction("rebase", "continue") } func (self *RebaseCommands) AbortRebase() error { return self.GenericMergeOrRebaseAction("rebase", "abort") } // GenericMerge takes a commandType of "merge" or "rebase" and a command of "abort", "skip" or "continue" // By default we skip the editor in the case where a commit will be made func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, command string) error { err := self.runSkipEditorCommand(self.GenericMergeOrRebaseActionCmdObj(commandType, command)) if err != nil { if !strings.Contains(err.Error(), "no rebase in progress") { return err } self.Log.Warn(err) } // sometimes we need to do a sequence of things in a rebase but the user needs to // fix merge conflicts along the way. When this happens we queue up the next step // so that after the next successful rebase continue we can continue from where we left off if commandType == "rebase" && command == "continue" && self.onSuccessfulContinue != nil { f := self.onSuccessfulContinue self.onSuccessfulContinue = nil return f() } if command == "abort" { self.onSuccessfulContinue = nil } return nil } func (self *RebaseCommands) runSkipEditorCommand(cmdObj oscommands.ICmdObj) error { instruction := daemon.NewExitImmediatelyInstruction() lazyGitPath := oscommands.GetLazygitPath() return cmdObj. AddEnvVars( "GIT_EDITOR="+lazyGitPath, "GIT_SEQUENCE_EDITOR="+lazyGitPath, "EDITOR="+lazyGitPath, "VISUAL="+lazyGitPath, ). AddEnvVars(daemon.ToEnvVars(instruction)...). Run() } // DiscardOldFileChanges discards changes to a file from an old commit func (self *RebaseCommands) DiscardOldFileChanges(commits []*models.Commit, commitIndex int, filePaths []string) error { if err := self.BeginInteractiveRebaseForCommit(commits, commitIndex, false); err != nil { return err } for _, filePath := range filePaths { // check if file exists in previous commit (this command returns an error if the file doesn't exist) cmdArgs := NewGitCmd("cat-file").Arg("-e", "HEAD^:"+filePath).ToArgv() if err := self.cmd.New(cmdArgs).Run(); err != nil { if err := self.os.Remove(filePath); err != nil { return err } if err := self.workingTree.StageFile(filePath); err != nil { return err } } else if err := self.workingTree.CheckoutFile("HEAD^", filePath); err != nil { return err } } // amend the commit err := self.commit.AmendHead() if err != nil { return err } // continue return self.ContinueRebase() } // CherryPickCommits begins an interactive rebase with the given hashes being cherry picked onto HEAD func (self *RebaseCommands) CherryPickCommits(commits []*models.Commit) error { hasMergeCommit := lo.SomeBy(commits, func(c *models.Commit) bool { return c.IsMerge() }) cmdArgs := NewGitCmd("cherry-pick"). Arg("--allow-empty"). ArgIf(self.version.IsAtLeast(2, 45, 0), "--empty=keep", "--keep-redundant-commits"). ArgIf(hasMergeCommit, "-m1"). Arg(lo.Reverse(lo.Map(commits, func(c *models.Commit, _ int) string { return c.Hash() }))...). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *RebaseCommands) DropMergeCommit(commits []*models.Commit, commitIndex int) error { return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: getBaseHashOrRoot(commits, commitIndex+1), instruction: daemon.NewDropMergeCommitInstruction(commits[commitIndex].Hash()), }).Run() } // we can't start an interactive rebase from the first commit without passing the // '--root' arg func getBaseHashOrRoot(commits []*models.Commit, index int) string { // We assume that the commits slice contains the initial commit of the repo. // Technically this assumption could prove false, but it's unlikely you'll // be starting a rebase from 300 commits ago (which is the original commit limit // at time of writing) if index < len(commits) { return commits[index].Hash() } else { return "--root" } } lazygit-0.50.0+ds1/pkg/commands/git_commands/rebase_test.go000066400000000000000000000132461500612110400236130ustar00rootroot00000000000000package git_commands import ( "regexp" "strconv" "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stretchr/testify/assert" ) func TestRebaseRebaseBranch(t *testing.T) { type scenario struct { testName string arg string gitVersion *GitVersion runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "successful rebase", arg: "master", gitVersion: &GitVersion{2, 26, 0, ""}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rebase", "--interactive", "--autostash", "--keep-empty", "--no-autosquash", "--rebase-merges", "master"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { testName: "unsuccessful rebase", arg: "master", gitVersion: &GitVersion{2, 26, 0, ""}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rebase", "--interactive", "--autostash", "--keep-empty", "--no-autosquash", "--rebase-merges", "master"}, "", errors.New("error")), test: func(err error) { assert.Error(t, err) }, }, { testName: "successful rebase (< 2.26.0)", arg: "master", gitVersion: &GitVersion{2, 25, 5, ""}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rebase", "--interactive", "--autostash", "--keep-empty", "--no-autosquash", "--rebase-merges", "master"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildRebaseCommands(commonDeps{runner: s.runner, gitVersion: s.gitVersion}) s.test(instance.RebaseBranch(s.arg)) }) } } // TestRebaseSkipEditorCommand confirms that SkipEditorCommand injects // environment variables that suppress an interactive editor func TestRebaseSkipEditorCommand(t *testing.T) { cmdArgs := []string{"git", "blah"} runner := oscommands.NewFakeRunner(t).ExpectFunc("matches editor env var", func(cmdObj oscommands.ICmdObj) bool { assert.EqualValues(t, cmdArgs, cmdObj.Args()) envVars := cmdObj.GetEnvVars() for _, regexStr := range []string{ `^VISUAL=.*$`, `^EDITOR=.*$`, `^GIT_EDITOR=.*$`, `^GIT_SEQUENCE_EDITOR=.*$`, "^" + daemon.DaemonKindEnvKey + "=" + strconv.Itoa(int(daemon.DaemonKindExitImmediately)) + "$", } { foundMatch := lo.ContainsBy(envVars, func(envVar string) bool { return regexp.MustCompile(regexStr).MatchString(envVar) }) if !foundMatch { return false } } return true }, "", nil) instance := buildRebaseCommands(commonDeps{runner: runner}) err := instance.runSkipEditorCommand(instance.cmd.New(cmdArgs)) assert.NoError(t, err) runner.CheckForMissingCalls() } func TestRebaseDiscardOldFileChanges(t *testing.T) { type scenario struct { testName string gitConfigMockResponses map[string]string commitOpts []models.NewCommitOpts commitIndex int fileName []string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "returns error when index outside of range of commits", gitConfigMockResponses: nil, commitOpts: []models.NewCommitOpts{}, commitIndex: 0, fileName: []string{"test999.txt"}, runner: oscommands.NewFakeRunner(t), test: func(err error) { assert.Error(t, err) }, }, { testName: "returns error when using gpg", gitConfigMockResponses: map[string]string{"commit.gpgSign": "true"}, commitOpts: []models.NewCommitOpts{{Name: "commit", Hash: "123456"}}, commitIndex: 0, fileName: []string{"test999.txt"}, runner: oscommands.NewFakeRunner(t), test: func(err error) { assert.Error(t, err) }, }, { testName: "checks out file if it already existed", gitConfigMockResponses: nil, commitOpts: []models.NewCommitOpts{ {Name: "commit", Hash: "123456"}, {Name: "commit2", Hash: "abcdef"}, }, commitIndex: 0, fileName: []string{"test999.txt"}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rebase", "--interactive", "--autostash", "--keep-empty", "--no-autosquash", "--rebase-merges", "abcdef"}, "", nil). ExpectGitArgs([]string{"cat-file", "-e", "HEAD^:test999.txt"}, "", nil). ExpectGitArgs([]string{"checkout", "HEAD^", "--", "test999.txt"}, "", nil). ExpectGitArgs([]string{"commit", "--amend", "--no-edit", "--allow-empty"}, "", nil). ExpectGitArgs([]string{"rebase", "--continue"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, // test for when the file was created within the commit requires a refactor to support proper mocks // currently we'd need to mock out the os.Remove function and that's gonna introduce tech debt } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildRebaseCommands(commonDeps{ runner: s.runner, gitVersion: &GitVersion{2, 26, 0, ""}, gitConfig: git_config.NewFakeGitConfig(s.gitConfigMockResponses), }) hashPool := &utils.StringPool{} commits := lo.Map(s.commitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) s.test(instance.DiscardOldFileChanges(commits, s.commitIndex, s.fileName)) s.runner.CheckForMissingCalls() }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/reflog_commit_loader.go000066400000000000000000000056541500612110400254730ustar00rootroot00000000000000package git_commands import ( "strconv" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" ) type ReflogCommitLoader struct { *common.Common cmd oscommands.ICmdObjBuilder } func NewReflogCommitLoader(common *common.Common, cmd oscommands.ICmdObjBuilder) *ReflogCommitLoader { return &ReflogCommitLoader{ Common: common, cmd: cmd, } } // GetReflogCommits only returns the new reflog commits since the given lastReflogCommit // if none is passed (i.e. it's value is nil) then we get all the reflog commits func (self *ReflogCommitLoader) GetReflogCommits(hashPool *utils.StringPool, lastReflogCommit *models.Commit, filterPath string, filterAuthor string) ([]*models.Commit, bool, error) { commits := make([]*models.Commit, 0) cmdArgs := NewGitCmd("log"). Config("log.showSignature=false"). Arg("-g"). Arg("--abbrev=40"). Arg("--format=%h%x00%ct%x00%gs%x00%P"). ArgIf(filterAuthor != "", "--author="+filterAuthor). ArgIf(filterPath != "", "--follow", "--", filterPath). ToArgv() cmdObj := self.cmd.New(cmdArgs).DontLog() onlyObtainedNewReflogCommits := false err := cmdObj.RunAndProcessLines(func(line string) (bool, error) { commit, ok := self.parseLine(hashPool, line) if !ok { return false, nil } // note that the unix timestamp here is the timestamp of the COMMIT, not the reflog entry itself, // so two consecutive reflog entries may have both the same hash and therefore same timestamp. // We use the reflog message to disambiguate, and fingers crossed that we never see the same of those // twice in a row. Reason being that it would mean we'd be erroneously exiting early. if lastReflogCommit != nil && self.sameReflogCommit(commit, lastReflogCommit) { onlyObtainedNewReflogCommits = true // after this point we already have these reflogs loaded so we'll simply return the new ones return true, nil } commits = append(commits, commit) return false, nil }) if err != nil { return nil, false, err } return commits, onlyObtainedNewReflogCommits, nil } func (self *ReflogCommitLoader) sameReflogCommit(a *models.Commit, b *models.Commit) bool { return a.Hash() == b.Hash() && a.UnixTimestamp == b.UnixTimestamp && a.Name == b.Name } func (self *ReflogCommitLoader) parseLine(hashPool *utils.StringPool, line string) (*models.Commit, bool) { fields := strings.SplitN(line, "\x00", 4) if len(fields) <= 3 { return nil, false } unixTimestamp, _ := strconv.Atoi(fields[1]) parentHashes := fields[3] parents := []string{} if len(parentHashes) > 0 { parents = strings.Split(parentHashes, " ") } return models.NewCommit(hashPool, models.NewCommitOpts{ Hash: fields[0], Name: fields[2], UnixTimestamp: int64(unixTimestamp), Status: models.StatusReflog, Parents: parents, }), true } lazygit-0.50.0+ds1/pkg/commands/git_commands/reflog_commit_loader_test.go000066400000000000000000000157071500612110400265320ustar00rootroot00000000000000package git_commands import ( "errors" "strings" "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sanity-io/litter" "github.com/stretchr/testify/assert" ) var reflogOutput = strings.Replace(`c3c4b66b64c97ffeecde|1643150483|checkout: moving from A to B|51baa8c1 c3c4b66b64c97ffeecde|1643150483|checkout: moving from B to A|51baa8c1 c3c4b66b64c97ffeecde|1643150483|checkout: moving from A to B|51baa8c1 c3c4b66b64c97ffeecde|1643150483|checkout: moving from master to A|51baa8c1 f4ddf2f0d4be4ccc7efa|1643149435|checkout: moving from A to master|51baa8c1 `, "|", "\x00", -1) func TestGetReflogCommits(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner lastReflogCommit *models.Commit filterPath string filterAuthor string expectedCommitOpts []models.NewCommitOpts expectedOnlyObtainedNew bool expectedError error } hashPool := &utils.StringPool{} scenarios := []scenario{ { testName: "no reflog entries", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-c", "log.showSignature=false", "log", "-g", "--abbrev=40", "--format=%h%x00%ct%x00%gs%x00%P"}, "", nil), lastReflogCommit: nil, expectedCommitOpts: []models.NewCommitOpts{}, expectedOnlyObtainedNew: false, expectedError: nil, }, { testName: "some reflog entries", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-c", "log.showSignature=false", "log", "-g", "--abbrev=40", "--format=%h%x00%ct%x00%gs%x00%P"}, reflogOutput, nil), lastReflogCommit: nil, expectedCommitOpts: []models.NewCommitOpts{ { Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from A to B", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }, { Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from B to A", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }, { Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from A to B", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }, { Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from master to A", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }, { Hash: "f4ddf2f0d4be4ccc7efa", Name: "checkout: moving from A to master", Status: models.StatusReflog, UnixTimestamp: 1643149435, Parents: []string{"51baa8c1"}, }, }, expectedOnlyObtainedNew: false, expectedError: nil, }, { testName: "some reflog entries where last commit is given", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-c", "log.showSignature=false", "log", "-g", "--abbrev=40", "--format=%h%x00%ct%x00%gs%x00%P"}, reflogOutput, nil), lastReflogCommit: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from B to A", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }), expectedCommitOpts: []models.NewCommitOpts{ { Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from A to B", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }, }, expectedOnlyObtainedNew: true, expectedError: nil, }, { testName: "when passing filterPath", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-c", "log.showSignature=false", "log", "-g", "--abbrev=40", "--format=%h%x00%ct%x00%gs%x00%P", "--follow", "--", "path"}, reflogOutput, nil), lastReflogCommit: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from B to A", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }), filterPath: "path", expectedCommitOpts: []models.NewCommitOpts{ { Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from A to B", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }, }, expectedOnlyObtainedNew: true, expectedError: nil, }, { testName: "when passing filterAuthor", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-c", "log.showSignature=false", "log", "-g", "--abbrev=40", "--format=%h%x00%ct%x00%gs%x00%P", "--author=John Doe "}, reflogOutput, nil), lastReflogCommit: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from B to A", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }), filterAuthor: "John Doe ", expectedCommitOpts: []models.NewCommitOpts{ { Hash: "c3c4b66b64c97ffeecde", Name: "checkout: moving from A to B", Status: models.StatusReflog, UnixTimestamp: 1643150483, Parents: []string{"51baa8c1"}, }, }, expectedOnlyObtainedNew: true, expectedError: nil, }, { testName: "when command returns error", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-c", "log.showSignature=false", "log", "-g", "--abbrev=40", "--format=%h%x00%ct%x00%gs%x00%P"}, "", errors.New("haha")), lastReflogCommit: nil, filterPath: "", expectedCommitOpts: nil, expectedOnlyObtainedNew: false, expectedError: errors.New("haha"), }, } for _, scenario := range scenarios { t.Run(scenario.testName, func(t *testing.T) { builder := &ReflogCommitLoader{ Common: utils.NewDummyCommon(), cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner), } commits, onlyObtainednew, err := builder.GetReflogCommits(hashPool, scenario.lastReflogCommit, scenario.filterPath, scenario.filterAuthor) assert.Equal(t, scenario.expectedOnlyObtainedNew, onlyObtainednew) assert.Equal(t, scenario.expectedError, err) t.Logf("actual commits: \n%s", litter.Sdump(commits)) var expectedCommits []*models.Commit if scenario.expectedCommitOpts != nil { expectedCommits = lo.Map(scenario.expectedCommitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) } assert.Equal(t, expectedCommits, commits) scenario.runner.CheckForMissingCalls() }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/remote.go000066400000000000000000000043301500612110400226000ustar00rootroot00000000000000package git_commands import ( "fmt" "strings" "github.com/jesseduffield/gocui" ) type RemoteCommands struct { *GitCommon } func NewRemoteCommands(gitCommon *GitCommon) *RemoteCommands { return &RemoteCommands{ GitCommon: gitCommon, } } func (self *RemoteCommands) AddRemote(name string, url string) error { cmdArgs := NewGitCmd("remote"). Arg("add", name, url). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *RemoteCommands) RemoveRemote(name string) error { cmdArgs := NewGitCmd("remote"). Arg("remove", name). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *RemoteCommands) RenameRemote(oldRemoteName string, newRemoteName string) error { cmdArgs := NewGitCmd("remote"). Arg("rename", oldRemoteName, newRemoteName). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *RemoteCommands) UpdateRemoteUrl(remoteName string, updatedUrl string) error { cmdArgs := NewGitCmd("remote"). Arg("set-url", remoteName, updatedUrl). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName string, branchNames []string) error { cmdArgs := NewGitCmd("push"). Arg(remoteName, "--delete"). Arg(branchNames...). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() } func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagName string) error { cmdArgs := NewGitCmd("push"). Arg(remoteName, "--delete", tagName). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() } // CheckRemoteBranchExists Returns remote branch func (self *RemoteCommands) CheckRemoteBranchExists(branchName string) bool { cmdArgs := NewGitCmd("show-ref"). Arg("--verify", "--", fmt.Sprintf("refs/remotes/origin/%s", branchName)). ToArgv() _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() return err == nil } // Resolve what might be a aliased URL into a full URL // SEE: `man -P 'less +/--get-url +n' git-ls-remote` func (self *RemoteCommands) GetRemoteURL(remoteName string) (string, error) { cmdArgs := NewGitCmd("ls-remote"). Arg("--get-url", remoteName). ToArgv() url, err := self.cmd.New(cmdArgs).RunWithOutput() return strings.TrimSpace(url), err } lazygit-0.50.0+ds1/pkg/commands/git_commands/remote_loader.go000066400000000000000000000061171500612110400241330ustar00rootroot00000000000000package git_commands import ( "fmt" "slices" "strings" "sync" gogit "github.com/jesseduffield/go-git/v5" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type RemoteLoader struct { *common.Common cmd oscommands.ICmdObjBuilder getGoGitRemotes func() ([]*gogit.Remote, error) } func NewRemoteLoader( common *common.Common, cmd oscommands.ICmdObjBuilder, getGoGitRemotes func() ([]*gogit.Remote, error), ) *RemoteLoader { return &RemoteLoader{ Common: common, cmd: cmd, getGoGitRemotes: getGoGitRemotes, } } func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) { wg := sync.WaitGroup{} wg.Add(1) var remoteBranchesByRemoteName map[string][]*models.RemoteBranch var remoteBranchesErr error go utils.Safe(func() { defer wg.Done() remoteBranchesByRemoteName, remoteBranchesErr = self.getRemoteBranchesByRemoteName() }) goGitRemotes, err := self.getGoGitRemotes() if err != nil { return nil, err } wg.Wait() if remoteBranchesErr != nil { return nil, remoteBranchesErr } remotes := lo.Map(goGitRemotes, func(goGitRemote *gogit.Remote, _ int) *models.Remote { remoteName := goGitRemote.Config().Name branches := remoteBranchesByRemoteName[remoteName] return &models.Remote{ Name: goGitRemote.Config().Name, Urls: goGitRemote.Config().URLs, Branches: branches, } }) // now lets sort our remotes by name alphabetically slices.SortFunc(remotes, func(a, b *models.Remote) int { // we want origin at the top because we'll be most likely to want it if a.Name == "origin" { return -1 } if b.Name == "origin" { return 1 } return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) return remotes, nil } func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) { remoteBranchesByRemoteName := make(map[string][]*models.RemoteBranch) var sortOrder string switch strings.ToLower(self.AppState.RemoteBranchSortOrder) { case "alphabetical": sortOrder = "refname" case "date": sortOrder = "-committerdate" default: sortOrder = "refname" } cmdArgs := NewGitCmd("for-each-ref"). Arg(fmt.Sprintf("--sort=%s", sortOrder)). Arg("--format=%(refname:short)"). Arg("refs/remotes"). ToArgv() err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) { line = strings.TrimSpace(line) split := strings.SplitN(line, "/", 2) if len(split) != 2 { return false, nil } remoteName := split[0] name := split[1] _, ok := remoteBranchesByRemoteName[remoteName] if !ok { remoteBranchesByRemoteName[remoteName] = []*models.RemoteBranch{} } remoteBranchesByRemoteName[remoteName] = append(remoteBranchesByRemoteName[remoteName], &models.RemoteBranch{ Name: name, RemoteName: remoteName, }) return false, nil }) if err != nil { return nil, err } return remoteBranchesByRemoteName, nil } lazygit-0.50.0+ds1/pkg/commands/git_commands/repo_paths.go000066400000000000000000000122771500612110400234620ustar00rootroot00000000000000package git_commands import ( ioFs "io/fs" "os" "path/filepath" "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/spf13/afero" ) type RepoPaths struct { worktreePath string worktreeGitDirPath string repoPath string repoGitDirPath string repoName string isBareRepo bool } var gitPathFormatVersion GitVersion = GitVersion{2, 31, 0, ""} // Path to the current worktree. If we're in the main worktree, this will // be the same as RepoPath() func (self *RepoPaths) WorktreePath() string { return self.worktreePath } // Path of the worktree's git dir. // If we're in the main worktree, this will be the .git dir under the RepoPath(). // If we're in a linked worktree, it will be the directory pointed at by the worktree's .git file func (self *RepoPaths) WorktreeGitDirPath() string { return self.worktreeGitDirPath } // Path of the repo. If we're in a the main worktree, this will be the same as WorktreePath() // If we're in a bare repo, it will be the parent folder of the bare repo func (self *RepoPaths) RepoPath() string { return self.repoPath } // path of the git-dir for the repo. // If this is a bare repo, it will be the location of the bare repo // If this is a non-bare repo, it will be the location of the .git dir in // the main worktree. func (self *RepoPaths) RepoGitDirPath() string { return self.repoGitDirPath } // Name of the repo. Basename of the folder containing the repo. func (self *RepoPaths) RepoName() string { return self.repoName } func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } // Returns the repo paths for a typical repo func MockRepoPaths(currentPath string) *RepoPaths { return &RepoPaths{ worktreePath: currentPath, worktreeGitDirPath: filepath.Join(currentPath, ".git"), repoPath: currentPath, repoGitDirPath: filepath.Join(currentPath, ".git"), repoName: "lazygit", isBareRepo: false, } } func GetRepoPaths( cmd oscommands.ICmdObjBuilder, version *GitVersion, ) (*RepoPaths, error) { cwd, err := os.Getwd() if err != nil { return nil, err } return GetRepoPathsForDir(cwd, cmd, version) } func GetRepoPathsForDir( dir string, cmd oscommands.ICmdObjBuilder, version *GitVersion, ) (*RepoPaths, error) { gitDirOutput, err := callGitRevParseWithDir(cmd, version, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree") if err != nil { return nil, err } gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n") worktreePath := gitDirResults[0] worktreeGitDirPath := gitDirResults[1] repoGitDirPath := gitDirResults[2] if version.IsOlderThanVersion(&gitPathFormatVersion) { repoGitDirPath, err = filepath.Abs(repoGitDirPath) if err != nil { return nil, err } } isBareRepo := gitDirResults[3] == "true" // If we're in a submodule, --show-superproject-working-tree will return // a value, meaning gitDirResults will be length 5. In that case // return the worktree path as the repoPath. Otherwise we're in a // normal repo or a worktree so return the parent of the git common // dir (repoGitDirPath) isSubmodule := len(gitDirResults) == 5 var repoPath string if isSubmodule { repoPath = worktreePath } else { repoPath = filepath.Dir(repoGitDirPath) } repoName := filepath.Base(repoPath) return &RepoPaths{ worktreePath: worktreePath, worktreeGitDirPath: worktreeGitDirPath, repoPath: repoPath, repoGitDirPath: repoGitDirPath, repoName: repoName, isBareRepo: isBareRepo, }, nil } func callGitRevParseWithDir( cmd oscommands.ICmdObjBuilder, version *GitVersion, dir string, gitRevArgs ...string, ) (string, error) { gitRevParse := NewGitCmd("rev-parse").ArgIf(version.IsAtLeastVersion(&gitPathFormatVersion), "--path-format=absolute").Arg(gitRevArgs...) if dir != "" { gitRevParse.Dir(dir) } gitCmd := cmd.New(gitRevParse.ToArgv()).DontLog() res, err := gitCmd.RunWithOutput() if err != nil { return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err) } return strings.TrimSpace(res), nil } // Returns the paths of linked worktrees func linkedWortkreePaths(fs afero.Fs, repoGitDirPath string) []string { result := []string{} // For each directory in this path we're going to cat the `gitdir` file and append its contents to our result // That file points us to the `.git` file in the worktree. worktreeGitDirsPath := filepath.Join(repoGitDirPath, "worktrees") // ensure the directory exists _, err := fs.Stat(worktreeGitDirsPath) if err != nil { return result } _ = afero.Walk(fs, worktreeGitDirsPath, func(currPath string, info ioFs.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { return nil } gitDirPath := filepath.Join(currPath, "gitdir") gitDirBytes, err := afero.ReadFile(fs, gitDirPath) if err != nil { // ignoring error return nil } trimmedGitDir := strings.TrimSpace(string(gitDirBytes)) // removing the .git part worktreeDir := filepath.Dir(trimmedGitDir) result = append(result, worktreeDir) return nil }) return result } lazygit-0.50.0+ds1/pkg/commands/git_commands/repo_paths_test.go000066400000000000000000000160141500612110400245120ustar00rootroot00000000000000package git_commands import ( "fmt" "runtime" "strings" "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/samber/lo" "github.com/stretchr/testify/assert" ) type ( argFn func() []string errFn func(getRevParseArgs argFn) error ) type Scenario struct { Name string BeforeFunc func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) Path string Expected *RepoPaths Err errFn } func TestGetRepoPaths(t *testing.T) { scenarios := []Scenario{ { Name: "typical case", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { // setup for main worktree mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ // --show-toplevel `C:\path\to\repo`, // --git-dir `C:\path\to\repo\.git`, // --git-common-dir `C:\path\to\repo\.git`, // --is-bare-repository "false", // --show-superproject-working-tree }, []string{ // --show-toplevel "/path/to/repo", // --git-dir "/path/to/repo/.git", // --git-common-dir "/path/to/repo/.git", // --is-bare-repository "false", // --show-superproject-working-tree }) runner.ExpectGitArgs( append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, Path: "/path/to/repo", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ worktreePath: `C:\path\to\repo`, worktreeGitDirPath: `C:\path\to\repo\.git`, repoPath: `C:\path\to\repo`, repoGitDirPath: `C:\path\to\repo\.git`, repoName: `repo`, isBareRepo: false, }, &RepoPaths{ worktreePath: "/path/to/repo", worktreeGitDirPath: "/path/to/repo/.git", repoPath: "/path/to/repo", repoGitDirPath: "/path/to/repo/.git", repoName: "repo", isBareRepo: false, }), Err: nil, }, { Name: "bare repo", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { // setup for main worktree mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ // --show-toplevel `C:\path\to\repo`, // --git-dir `C:\path\to\bare_repo\bare.git`, // --git-common-dir `C:\path\to\bare_repo\bare.git`, // --is-bare-repository `true`, // --show-superproject-working-tree }, []string{ // --show-toplevel "/path/to/repo", // --git-dir "/path/to/bare_repo/bare.git", // --git-common-dir "/path/to/bare_repo/bare.git", // --is-bare-repository "true", // --show-superproject-working-tree }) runner.ExpectGitArgs( append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, Path: "/path/to/repo", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ worktreePath: `C:\path\to\repo`, worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`, repoPath: `C:\path\to\bare_repo`, repoGitDirPath: `C:\path\to\bare_repo\bare.git`, repoName: `bare_repo`, isBareRepo: true, }, &RepoPaths{ worktreePath: "/path/to/repo", worktreeGitDirPath: "/path/to/bare_repo/bare.git", repoPath: "/path/to/bare_repo", repoGitDirPath: "/path/to/bare_repo/bare.git", repoName: "bare_repo", isBareRepo: true, }), Err: nil, }, { Name: "submodule", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ // --show-toplevel `C:\path\to\repo\submodule1`, // --git-dir `C:\path\to\repo\.git\modules\submodule1`, // --git-common-dir `C:\path\to\repo\.git\modules\submodule1`, // --is-bare-repository `false`, // --show-superproject-working-tree `C:\path\to\repo`, }, []string{ // --show-toplevel "/path/to/repo/submodule1", // --git-dir "/path/to/repo/.git/modules/submodule1", // --git-common-dir "/path/to/repo/.git/modules/submodule1", // --is-bare-repository "false", // --show-superproject-working-tree "/path/to/repo", }) runner.ExpectGitArgs( append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, Path: "/path/to/repo/submodule1", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ worktreePath: `C:\path\to\repo\submodule1`, worktreeGitDirPath: `C:\path\to\repo\.git\modules\submodule1`, repoPath: `C:\path\to\repo\submodule1`, repoGitDirPath: `C:\path\to\repo\.git\modules\submodule1`, repoName: `submodule1`, isBareRepo: false, }, &RepoPaths{ worktreePath: "/path/to/repo/submodule1", worktreeGitDirPath: "/path/to/repo/.git/modules/submodule1", repoPath: "/path/to/repo/submodule1", repoGitDirPath: "/path/to/repo/.git/modules/submodule1", repoName: "submodule1", isBareRepo: false, }), Err: nil, }, { Name: "git rev-parse returns an error", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { runner.ExpectGitArgs( append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), "", errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git")) }, Path: "/path/to/repo/worktree2", Expected: nil, Err: func(getRevParseArgs argFn) error { args := strings.Join(getRevParseArgs(), " ") return errors.New( fmt.Sprintf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --is-bare-repository --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args), ) }, }, } for _, s := range scenarios { t.Run(s.Name, func(t *testing.T) { runner := oscommands.NewFakeRunner(t) cmd := oscommands.NewDummyCmdObjBuilder(runner) version, err := GetGitVersion(oscommands.NewDummyOSCommand()) if err != nil { t.Fatal(err) } getRevParseArgs := func() []string { args := []string{"rev-parse"} if version.IsAtLeast(2, 31, 0) { args = append(args, "--path-format=absolute") } return args } // prepare the filesystem for the scenario s.BeforeFunc(runner, getRevParseArgs) repoPaths, err := GetRepoPathsForDir("", cmd, version) // check the error and the paths if s.Err != nil { scenarioErr := s.Err(getRevParseArgs) assert.Error(t, err) assert.EqualError(t, err, scenarioErr.Error()) } else { assert.Nil(t, err) assert.Equal(t, s.Expected, repoPaths) } }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/stash.go000066400000000000000000000127511500612110400224350ustar00rootroot00000000000000package git_commands import ( "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) type StashCommands struct { *GitCommon fileLoader *FileLoader workingTree *WorkingTreeCommands } func NewStashCommands( gitCommon *GitCommon, fileLoader *FileLoader, workingTree *WorkingTreeCommands, ) *StashCommands { return &StashCommands{ GitCommon: gitCommon, fileLoader: fileLoader, workingTree: workingTree, } } func (self *StashCommands) DropNewest() error { cmdArgs := NewGitCmd("stash").Arg("drop").ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *StashCommands) Drop(index int) error { cmdArgs := NewGitCmd("stash").Arg("drop", fmt.Sprintf("stash@{%d}", index)). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *StashCommands) Pop(index int) error { cmdArgs := NewGitCmd("stash").Arg("pop", fmt.Sprintf("stash@{%d}", index)). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *StashCommands) Apply(index int) error { cmdArgs := NewGitCmd("stash").Arg("apply", fmt.Sprintf("stash@{%d}", index)). ToArgv() return self.cmd.New(cmdArgs).Run() } // Push push stash func (self *StashCommands) Push(message string) error { cmdArgs := NewGitCmd("stash").Arg("push", "-m", message). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *StashCommands) Store(hash string, message string) error { trimmedMessage := strings.Trim(message, " \t") cmdArgs := NewGitCmd("stash").Arg("store"). ArgIf(trimmedMessage != "", "-m", trimmedMessage). Arg(hash). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *StashCommands) Hash(index int) (string, error) { cmdArgs := NewGitCmd("rev-parse"). Arg(fmt.Sprintf("refs/stash@{%d}", index)). ToArgv() hash, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs() return strings.Trim(hash, "\r\n"), err } func (self *StashCommands) ShowStashEntryCmdObj(index int) oscommands.ICmdObj { // "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason cmdArgs := NewGitCmd("stash").Arg("show"). Arg("-p"). Arg("--stat"). Arg("-u"). Arg(fmt.Sprintf("--color=%s", self.UserConfig().Git.Paging.ColorArg)). Arg(fmt.Sprintf("--unified=%d", self.AppState.DiffContextSize)). ArgIf(self.AppState.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg(fmt.Sprintf("--find-renames=%d%%", self.AppState.RenameSimilarityThreshold)). Arg(fmt.Sprintf("stash@{%d}", index)). Dir(self.repoPaths.worktreePath). ToArgv() return self.cmd.New(cmdArgs).DontLog() } func (self *StashCommands) StashAndKeepIndex(message string) error { cmdArgs := NewGitCmd("stash").Arg("push", "--keep-index", "-m", message). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *StashCommands) StashUnstagedChanges(message string) error { if err := self.cmd.New( NewGitCmd("commit"). Arg("--no-verify", "-m", "[lazygit] stashing unstaged changes"). ToArgv(), ).Run(); err != nil { return err } if err := self.Push(message); err != nil { return err } if err := self.cmd.New( NewGitCmd("reset").Arg("--soft", "HEAD^").ToArgv(), ).Run(); err != nil { return err } return nil } // SaveStagedChanges stashes only the currently staged changes. func (self *StashCommands) SaveStagedChanges(message string) error { if self.version.IsAtLeast(2, 35, 0) { return self.cmd.New(NewGitCmd("stash").Arg("push").Arg("--staged").Arg("-m", message).ToArgv()).Run() } // Git versions older than 2.35.0 don't support the --staged flag, so we // need to fall back to a more complex solution. // Shoutouts to Joe on https://stackoverflow.com/questions/14759748/stashing-only-staged-changes-in-git-is-it-possible // // Note that this method has a few bugs: // - it fails when there are *only* staged changes // - it fails when staged and unstaged changes within a single file are too close together // We don't bother fixing these, because users can simply update git when // they are affected by these issues. // wrap in 'writing', which uses a mutex if err := self.cmd.New( NewGitCmd("stash").Arg("--keep-index").ToArgv(), ).Run(); err != nil { return err } if err := self.Push(message); err != nil { return err } if err := self.cmd.New( NewGitCmd("stash").Arg("apply", "stash@{1}").ToArgv(), ).Run(); err != nil { return err } if err := self.os.PipeCommands( self.cmd.New(NewGitCmd("stash").Arg("show", "-p").ToArgv()), self.cmd.New(NewGitCmd("apply").Arg("-R").ToArgv()), ); err != nil { return err } if err := self.cmd.New( NewGitCmd("stash").Arg("drop", "stash@{1}").ToArgv(), ).Run(); err != nil { return err } // if you had staged an untracked file, that will now appear as 'AD' in git status // meaning it's deleted in your working tree but added in your index. Given that it's // now safely stashed, we need to remove it. files := self.fileLoader. GetStatusFiles(GetStatusFileOptions{}) for _, file := range files { if file.ShortStatus == "AD" { if err := self.workingTree.UnStageFile(file.Names(), false); err != nil { return err } } } return nil } func (self *StashCommands) StashIncludeUntrackedChanges(message string) error { return self.cmd.New( NewGitCmd("stash").Arg("push", "--include-untracked", "-m", message). ToArgv(), ).Run() } func (self *StashCommands) Rename(index int, message string) error { hash, err := self.Hash(index) if err != nil { return err } if err := self.Drop(index); err != nil { return err } err = self.Store(hash, message) if err != nil { return err } return nil } lazygit-0.50.0+ds1/pkg/commands/git_commands/stash_loader.go000066400000000000000000000045171500612110400237640ustar00rootroot00000000000000package git_commands import ( "regexp" "strconv" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type StashLoader struct { *common.Common cmd oscommands.ICmdObjBuilder } func NewStashLoader( common *common.Common, cmd oscommands.ICmdObjBuilder, ) *StashLoader { return &StashLoader{ Common: common, cmd: cmd, } } func (self *StashLoader) GetStashEntries(filterPath string) []*models.StashEntry { if filterPath == "" { return self.getUnfilteredStashEntries() } cmdArgs := NewGitCmd("stash").Arg("list", "-z", "--name-only", "--pretty=%ct|%gs").ToArgv() rawString, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() if err != nil { return self.getUnfilteredStashEntries() } stashEntries := []*models.StashEntry{} var currentStashEntry *models.StashEntry lines := utils.SplitNul(rawString) isAStash := func(line string) bool { return strings.HasPrefix(line, "stash@{") } re := regexp.MustCompile(`stash@\{(\d+)\}`) outer: for i := 0; i < len(lines); i++ { if !isAStash(lines[i]) { continue } match := re.FindStringSubmatch(lines[i]) idx, err := strconv.Atoi(match[1]) if err != nil { return self.getUnfilteredStashEntries() } currentStashEntry = self.stashEntryFromLine(lines[i], idx) for i+1 < len(lines) && !isAStash(lines[i+1]) { i++ if lines[i] == filterPath { stashEntries = append(stashEntries, currentStashEntry) continue outer } } } return stashEntries } func (self *StashLoader) getUnfilteredStashEntries() []*models.StashEntry { cmdArgs := NewGitCmd("stash").Arg("list", "-z", "--pretty=%ct|%gs").ToArgv() rawString, _ := self.cmd.New(cmdArgs).DontLog().RunWithOutput() return lo.Map(utils.SplitNul(rawString), func(line string, index int) *models.StashEntry { return self.stashEntryFromLine(line, index) }) } func (c *StashLoader) stashEntryFromLine(line string, index int) *models.StashEntry { model := &models.StashEntry{ Name: line, Index: index, } tstr, msg, ok := strings.Cut(line, "|") if !ok { return model } t, err := strconv.ParseInt(tstr, 10, 64) if err != nil { return model } model.Name = msg model.Recency = utils.UnixToTimeAgo(t) return model } lazygit-0.50.0+ds1/pkg/commands/git_commands/stash_loader_test.go000066400000000000000000000027111500612110400250150ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) func TestGetStashEntries(t *testing.T) { type scenario struct { testName string filterPath string runner oscommands.ICmdObjRunner expectedStashEntries []*models.StashEntry } scenarios := []scenario{ { "No stash entries found", "", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"stash", "list", "-z", "--pretty=%ct|%gs"}, "", nil), []*models.StashEntry{}, }, { "Several stash entries found", "", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"stash", "list", "-z", "--pretty=%ct|%gs"}, "WIP on add-pkg-commands-test: 55c6af2 increase parallel build\x00WIP on master: bb86a3f update github template\x00", nil, ), []*models.StashEntry{ { Index: 0, Name: "WIP on add-pkg-commands-test: 55c6af2 increase parallel build", }, { Index: 1, Name: "WIP on master: bb86a3f update github template", }, }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { cmd := oscommands.NewDummyCmdObjBuilder(s.runner) loader := NewStashLoader(utils.NewDummyCommon(), cmd) assert.EqualValues(t, s.expectedStashEntries, loader.GetStashEntries(s.filterPath)) }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/stash_test.go000066400000000000000000000146101500612110400234700ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/stretchr/testify/assert" ) func TestStashDrop(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"stash", "drop", "stash@{1}"}, "Dropped refs/stash@{1} (98e9cca532c37c766107093010c72e26f2c24c04)\n", nil) instance := buildStashCommands(commonDeps{runner: runner}) assert.NoError(t, instance.Drop(1)) runner.CheckForMissingCalls() } func TestStashApply(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"stash", "apply", "stash@{1}"}, "", nil) instance := buildStashCommands(commonDeps{runner: runner}) assert.NoError(t, instance.Apply(1)) runner.CheckForMissingCalls() } func TestStashPop(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"stash", "pop", "stash@{1}"}, "", nil) instance := buildStashCommands(commonDeps{runner: runner}) assert.NoError(t, instance.Pop(1)) runner.CheckForMissingCalls() } func TestStashSave(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"stash", "push", "-m", "A stash message"}, "", nil) instance := buildStashCommands(commonDeps{runner: runner}) assert.NoError(t, instance.Push("A stash message")) runner.CheckForMissingCalls() } func TestStashStore(t *testing.T) { type scenario struct { testName string hash string message string expected []string } scenarios := []scenario{ { testName: "Non-empty message", hash: "0123456789abcdef", message: "New stash name", expected: []string{"stash", "store", "-m", "New stash name", "0123456789abcdef"}, }, { testName: "Empty message", hash: "0123456789abcdef", message: "", expected: []string{"stash", "store", "0123456789abcdef"}, }, { testName: "Space message", hash: "0123456789abcdef", message: " ", expected: []string{"stash", "store", "0123456789abcdef"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs(s.expected, "", nil) instance := buildStashCommands(commonDeps{runner: runner}) assert.NoError(t, instance.Store(s.hash, s.message)) runner.CheckForMissingCalls() }) } } func TestStashHash(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rev-parse", "refs/stash@{5}"}, "14d94495194651adfd5f070590df566c11d28243\n", nil) instance := buildStashCommands(commonDeps{runner: runner}) hash, err := instance.Hash(5) assert.NoError(t, err) assert.Equal(t, "14d94495194651adfd5f070590df566c11d28243", hash) runner.CheckForMissingCalls() } func TestStashStashEntryCmdObj(t *testing.T) { type scenario struct { testName string index int contextSize uint64 similarityThreshold int ignoreWhitespace bool expected []string } scenarios := []scenario{ { testName: "Default case", index: 5, contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--color=always", "--unified=3", "--find-renames=50%", "stash@{5}"}, }, { testName: "Show diff with custom context size", index: 5, contextSize: 77, similarityThreshold: 50, ignoreWhitespace: false, expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--color=always", "--unified=77", "--find-renames=50%", "stash@{5}"}, }, { testName: "Show diff with custom similarity threshold", index: 5, contextSize: 3, similarityThreshold: 33, ignoreWhitespace: false, expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--color=always", "--unified=3", "--find-renames=33%", "stash@{5}"}, }, { testName: "Default case", index: 5, contextSize: 3, similarityThreshold: 50, ignoreWhitespace: true, expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--color=always", "--unified=3", "--ignore-all-space", "--find-renames=50%", "stash@{5}"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() appState := &config.AppState{} appState.IgnoreWhitespaceInDiffView = s.ignoreWhitespace appState.DiffContextSize = s.contextSize appState.RenameSimilarityThreshold = s.similarityThreshold repoPaths := RepoPaths{ worktreePath: "/path/to/worktree", } instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: appState, repoPaths: &repoPaths}) cmdStr := instance.ShowStashEntryCmdObj(s.index).Args() assert.Equal(t, s.expected, cmdStr) }) } } func TestStashRename(t *testing.T) { type scenario struct { testName string index int message string expectedHashCmd []string hashResult string expectedDropCmd []string expectedStoreCmd []string } scenarios := []scenario{ { testName: "Default case", index: 3, message: "New message", expectedHashCmd: []string{"rev-parse", "refs/stash@{3}"}, hashResult: "f0d0f20f2f61ffd6d6bfe0752deffa38845a3edd\n", expectedDropCmd: []string{"stash", "drop", "stash@{3}"}, expectedStoreCmd: []string{"stash", "store", "-m", "New message", "f0d0f20f2f61ffd6d6bfe0752deffa38845a3edd"}, }, { testName: "Empty message", index: 4, message: "", expectedHashCmd: []string{"rev-parse", "refs/stash@{4}"}, hashResult: "f0d0f20f2f61ffd6d6bfe0752deffa38845a3edd\n", expectedDropCmd: []string{"stash", "drop", "stash@{4}"}, expectedStoreCmd: []string{"stash", "store", "f0d0f20f2f61ffd6d6bfe0752deffa38845a3edd"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs(s.expectedHashCmd, s.hashResult, nil). ExpectGitArgs(s.expectedDropCmd, "", nil). ExpectGitArgs(s.expectedStoreCmd, "", nil) instance := buildStashCommands(commonDeps{runner: runner}) err := instance.Rename(s.index, s.message) assert.NoError(t, err) }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/status.go000066400000000000000000000063151500612110400226350ustar00rootroot00000000000000package git_commands import ( "os" "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" ) type StatusCommands struct { *GitCommon } func NewStatusCommands( gitCommon *GitCommon, ) *StatusCommands { return &StatusCommands{ GitCommon: gitCommon, } } func (self *StatusCommands) WorkingTreeState() models.WorkingTreeState { result := models.WorkingTreeState{} result.Rebasing, _ = self.IsInRebase() result.Merging, _ = self.IsInMergeState() result.CherryPicking, _ = self.IsInCherryPick() result.Reverting, _ = self.IsInRevert() return result } func (self *StatusCommands) IsBareRepo() bool { return self.repoPaths.isBareRepo } func (self *StatusCommands) IsInRebase() (bool, error) { exists, err := self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge")) if err == nil && exists { return true, nil } return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply")) } // IsInMergeState states whether we are still mid-merge func (self *StatusCommands) IsInMergeState() (bool, error) { return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "MERGE_HEAD")) } func (self *StatusCommands) IsInCherryPick() (bool, error) { exists, err := self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "CHERRY_PICK_HEAD")) if err != nil || !exists { return exists, err } // Sometimes, CHERRY_PICK_HEAD is present during rebases even if no // cherry-pick is in progress. I suppose this is because rebase used to be // implemented as a series of cherry-picks, so this could be remnants of // code that is shared between cherry-pick and rebase, or something. The way // to tell if this is the case is to check for the presence of the // stopped-sha file, which records the sha of the last pick that was // executed before the rebase stopped, and seeing if the sha in that file is // the same as the one in CHERRY_PICK_HEAD. cherryPickHead, err := os.ReadFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "CHERRY_PICK_HEAD")) if err != nil { return false, err } stoppedSha, err := os.ReadFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge", "stopped-sha")) if err != nil { // If we get an error we assume the file doesn't exist return true, nil } cherryPickHeadStr := strings.TrimSpace(string(cherryPickHead)) stoppedShaStr := strings.TrimSpace(string(stoppedSha)) // Need to use HasPrefix here because the cherry-pick HEAD is a full sha1, // but stopped-sha is an abbreviated sha1 if strings.HasPrefix(cherryPickHeadStr, stoppedShaStr) { return false, nil } return true, nil } func (self *StatusCommands) IsInRevert() (bool, error) { return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "REVERT_HEAD")) } // Full ref (e.g. "refs/heads/mybranch") of the branch that is currently // being rebased, or empty string when we're not in a rebase func (self *StatusCommands) BranchBeingRebased() string { for _, dir := range []string{"rebase-merge", "rebase-apply"} { if bytesContent, err := os.ReadFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), dir, "head-name")); err == nil { return strings.TrimSpace(string(bytesContent)) } } return "" } lazygit-0.50.0+ds1/pkg/commands/git_commands/submodule.go000066400000000000000000000155161500612110400233140ustar00rootroot00000000000000package git_commands import ( "bufio" "os" "path/filepath" "regexp" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) // .gitmodules looks like this: // [submodule "mysubmodule"] // path = blah/mysubmodule // url = git@github.com:subbo.git type SubmoduleCommands struct { *GitCommon } func NewSubmoduleCommands(gitCommon *GitCommon) *SubmoduleCommands { return &SubmoduleCommands{ GitCommon: gitCommon, } } func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig) ([]*models.SubmoduleConfig, error) { gitModulesPath := ".gitmodules" if parentModule != nil { gitModulesPath = filepath.Join(parentModule.FullPath(), gitModulesPath) } file, err := os.Open(gitModulesPath) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } defer file.Close() scanner := bufio.NewScanner(file) scanner.Split(bufio.ScanLines) firstMatch := func(str string, regex string) (string, bool) { re := regexp.MustCompile(regex) matches := re.FindStringSubmatch(str) if len(matches) > 0 { return matches[1], true } else { return "", false } } configs := []*models.SubmoduleConfig{} lastConfigIdx := -1 for scanner.Scan() { line := scanner.Text() if name, ok := firstMatch(line, `\[submodule "(.*)"\]`); ok { configs = append(configs, &models.SubmoduleConfig{ Name: name, ParentModule: parentModule, }) lastConfigIdx = len(configs) - 1 continue } if lastConfigIdx != -1 { if path, ok := firstMatch(line, `\s*path\s*=\s*(.*)\s*`); ok { configs[lastConfigIdx].Path = path nestedConfigs, err := self.GetConfigs(configs[lastConfigIdx]) if err == nil { configs = append(configs, nestedConfigs...) } } else if url, ok := firstMatch(line, `\s*url\s*=\s*(.*)\s*`); ok { configs[lastConfigIdx].Url = url } } } return configs, nil } func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { // if the path does not exist then it hasn't yet been initialized so we'll swallow the error // because the intention here is to have no dirty worktree state if _, err := os.Stat(submodule.Path); os.IsNotExist(err) { self.Log.Infof("submodule path %s does not exist, returning", submodule.FullPath()) return nil } cmdArgs := NewGitCmd("stash"). Dir(submodule.FullPath()). Arg("--include-untracked"). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error { parentDir := "" if submodule.ParentModule != nil { parentDir = submodule.ParentModule.FullPath() } cmdArgs := NewGitCmd("submodule"). Arg("update", "--init", "--force", "--", submodule.Path). DirIf(parentDir != "", parentDir). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *SubmoduleCommands) UpdateAll() error { // not doing an --init here because the user probably doesn't want that cmdArgs := NewGitCmd("submodule").Arg("update", "--force").ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error { // based on https://gist.github.com/myusuf3/7f645819ded92bda6677 if submodule.ParentModule != nil { wd, err := os.Getwd() if err != nil { return err } err = os.Chdir(submodule.ParentModule.FullPath()) if err != nil { return err } defer func() { _ = os.Chdir(wd) }() } if err := self.cmd.New( NewGitCmd("submodule"). Arg("deinit", "--force", "--", submodule.Path).ToArgv(), ).Run(); err != nil { if !strings.Contains(err.Error(), "did not match any file(s) known to git") { return err } if err := self.cmd.New( NewGitCmd("config"). Arg("--file", ".gitmodules", "--remove-section", "submodule."+submodule.Path). ToArgv(), ).Run(); err != nil { return err } if err := self.cmd.New( NewGitCmd("config"). Arg("--remove-section", "submodule."+submodule.Path). ToArgv(), ).Run(); err != nil { return err } } if err := self.cmd.New( NewGitCmd("rm").Arg("--force", "-r", submodule.Path).ToArgv(), ).Run(); err != nil { // if the directory isn't there then that's fine self.Log.Error(err) } // We may in fact want to use the repo's git dir path but git docs say not to // mix submodules and worktrees anyway. return os.RemoveAll(submodule.GitDirPath(self.repoPaths.repoGitDirPath)) } func (self *SubmoduleCommands) Add(name string, path string, url string) error { cmdArgs := NewGitCmd("submodule"). Arg("add"). Arg("--force"). Arg("--name"). Arg(name). Arg("--"). Arg(url). Arg(path). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newUrl string) error { if submodule.ParentModule != nil { wd, err := os.Getwd() if err != nil { return err } err = os.Chdir(submodule.ParentModule.FullPath()) if err != nil { return err } defer func() { _ = os.Chdir(wd) }() } setUrlCmdStr := NewGitCmd("config"). Arg( "--file", ".gitmodules", "submodule."+submodule.Name+".url", newUrl, ). ToArgv() // the set-url command is only for later git versions so we're doing it manually here if err := self.cmd.New(setUrlCmdStr).Run(); err != nil { return err } syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", submodule.Path). ToArgv() if err := self.cmd.New(syncCmdStr).Run(); err != nil { return err } return nil } func (self *SubmoduleCommands) Init(path string) error { cmdArgs := NewGitCmd("submodule").Arg("init", "--", path). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *SubmoduleCommands) Update(path string) error { cmdArgs := NewGitCmd("submodule").Arg("update", "--init", "--", path). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *SubmoduleCommands) BulkInitCmdObj() oscommands.ICmdObj { cmdArgs := NewGitCmd("submodule").Arg("init"). ToArgv() return self.cmd.New(cmdArgs) } func (self *SubmoduleCommands) BulkUpdateCmdObj() oscommands.ICmdObj { cmdArgs := NewGitCmd("submodule").Arg("update"). ToArgv() return self.cmd.New(cmdArgs) } func (self *SubmoduleCommands) ForceBulkUpdateCmdObj() oscommands.ICmdObj { cmdArgs := NewGitCmd("submodule").Arg("update", "--force"). ToArgv() return self.cmd.New(cmdArgs) } func (self *SubmoduleCommands) BulkUpdateRecursivelyCmdObj() oscommands.ICmdObj { cmdArgs := NewGitCmd("submodule").Arg("update", "--init", "--recursive"). ToArgv() return self.cmd.New(cmdArgs) } func (self *SubmoduleCommands) BulkDeinitCmdObj() oscommands.ICmdObj { cmdArgs := NewGitCmd("submodule").Arg("deinit", "--all", "--force"). ToArgv() return self.cmd.New(cmdArgs) } func (self *SubmoduleCommands) ResetSubmodules(submodules []*models.SubmoduleConfig) error { for _, submodule := range submodules { if err := self.Stash(submodule); err != nil { return err } } return self.UpdateAll() } lazygit-0.50.0+ds1/pkg/commands/git_commands/sync.go000066400000000000000000000071561500612110400222720ustar00rootroot00000000000000package git_commands import ( "fmt" "github.com/go-errors/errors" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) type SyncCommands struct { *GitCommon } func NewSyncCommands(gitCommon *GitCommon) *SyncCommands { return &SyncCommands{ GitCommon: gitCommon, } } // Push pushes to a branch type PushOpts struct { Force bool ForceWithLease bool CurrentBranch string UpstreamRemote string UpstreamBranch string SetUpstream bool } func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (oscommands.ICmdObj, error) { if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" { return nil, errors.New(self.Tr.MustSpecifyOriginError) } cmdArgs := NewGitCmd("push"). ArgIf(opts.Force, "--force"). ArgIf(opts.ForceWithLease, "--force-with-lease"). ArgIf(opts.SetUpstream, "--set-upstream"). ArgIf(opts.UpstreamRemote != "", opts.UpstreamRemote). ArgIf(opts.UpstreamBranch != "", fmt.Sprintf("refs/heads/%s:%s", opts.CurrentBranch, opts.UpstreamBranch)). ToArgv() cmdObj := self.cmd.New(cmdArgs).PromptOnCredentialRequest(task) return cmdObj, nil } func (self *SyncCommands) Push(task gocui.Task, opts PushOpts) error { cmdObj, err := self.PushCmdObj(task, opts) if err != nil { return err } return cmdObj.Run() } func (self *SyncCommands) fetchCommandBuilder(fetchAll bool) *GitCommandBuilder { return NewGitCmd("fetch"). ArgIf(fetchAll, "--all"). // avoid writing to .git/FETCH_HEAD; this allows running a pull // concurrently without getting errors ArgIf(self.version.IsAtLeast(2, 29, 0), "--no-write-fetch-head") } func (self *SyncCommands) FetchCmdObj(task gocui.Task) oscommands.ICmdObj { cmdArgs := self.fetchCommandBuilder(self.UserConfig().Git.FetchAll).ToArgv() cmdObj := self.cmd.New(cmdArgs) cmdObj.PromptOnCredentialRequest(task) return cmdObj } func (self *SyncCommands) Fetch(task gocui.Task) error { return self.FetchCmdObj(task).Run() } func (self *SyncCommands) FetchBackgroundCmdObj() oscommands.ICmdObj { cmdArgs := self.fetchCommandBuilder(self.UserConfig().Git.FetchAll).ToArgv() cmdObj := self.cmd.New(cmdArgs) cmdObj.DontLog().FailOnCredentialRequest() return cmdObj } func (self *SyncCommands) FetchBackground() error { return self.FetchBackgroundCmdObj().Run() } type PullOptions struct { RemoteName string BranchName string FastForwardOnly bool WorktreeGitDir string WorktreePath string } func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error { cmdArgs := NewGitCmd("pull"). Arg("--no-edit"). ArgIf(opts.FastForwardOnly, "--ff-only"). ArgIf(opts.RemoteName != "", opts.RemoteName). ArgIf(opts.BranchName != "", "refs/heads/"+opts.BranchName). GitDirIf(opts.WorktreeGitDir != "", opts.WorktreeGitDir). WorktreePathIf(opts.WorktreePath != "", opts.WorktreePath). ToArgv() // setting GIT_SEQUENCE_EDITOR to ':' as a way of skipping it, in case the user // has 'pull.rebase = interactive' configured. return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run() } func (self *SyncCommands) FastForward( task gocui.Task, branchName string, remoteName string, remoteBranchName string, ) error { cmdArgs := self.fetchCommandBuilder(false). Arg(remoteName). Arg("refs/heads/" + remoteBranchName + ":" + branchName). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() } func (self *SyncCommands) FetchRemote(task gocui.Task, remoteName string) error { cmdArgs := self.fetchCommandBuilder(false). Arg(remoteName). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() } lazygit-0.50.0+ds1/pkg/commands/git_commands/sync_test.go000066400000000000000000000117621500612110400233270ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/stretchr/testify/assert" ) func TestSyncPush(t *testing.T) { type scenario struct { testName string opts PushOpts test func(oscommands.ICmdObj, error) } scenarios := []scenario{ { testName: "Push with force disabled", opts: PushOpts{ForceWithLease: false}, test: func(cmdObj oscommands.ICmdObj, err error) { assert.Equal(t, cmdObj.Args(), []string{"git", "push"}) assert.NoError(t, err) }, }, { testName: "Push with force-with-lease enabled", opts: PushOpts{ForceWithLease: true}, test: func(cmdObj oscommands.ICmdObj, err error) { assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease"}) assert.NoError(t, err) }, }, { testName: "Push with force enabled", opts: PushOpts{Force: true}, test: func(cmdObj oscommands.ICmdObj, err error) { assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force"}) assert.NoError(t, err) }, }, { testName: "Push with force disabled, upstream supplied", opts: PushOpts{ ForceWithLease: false, CurrentBranch: "master", UpstreamRemote: "origin", UpstreamBranch: "master", }, test: func(cmdObj oscommands.ICmdObj, err error) { assert.Equal(t, cmdObj.Args(), []string{"git", "push", "origin", "refs/heads/master:master"}) assert.NoError(t, err) }, }, { testName: "Push with force disabled, setting upstream", opts: PushOpts{ ForceWithLease: false, CurrentBranch: "master-local", UpstreamRemote: "origin", UpstreamBranch: "master", SetUpstream: true, }, test: func(cmdObj oscommands.ICmdObj, err error) { assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--set-upstream", "origin", "refs/heads/master-local:master"}) assert.NoError(t, err) }, }, { testName: "Push with force-with-lease enabled, setting upstream", opts: PushOpts{ ForceWithLease: true, CurrentBranch: "master", UpstreamRemote: "origin", UpstreamBranch: "master", SetUpstream: true, }, test: func(cmdObj oscommands.ICmdObj, err error) { assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease", "--set-upstream", "origin", "refs/heads/master:master"}) assert.NoError(t, err) }, }, { testName: "Push with remote branch but no origin", opts: PushOpts{ ForceWithLease: true, UpstreamRemote: "", UpstreamBranch: "master", SetUpstream: true, }, test: func(cmdObj oscommands.ICmdObj, err error) { assert.Error(t, err) assert.EqualValues(t, "Must specify a remote if specifying a branch", err.Error()) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildSyncCommands(commonDeps{}) task := gocui.NewFakeTask() s.test(instance.PushCmdObj(task, s.opts)) }) } } func TestSyncFetch(t *testing.T) { type scenario struct { testName string fetchAllConfig bool test func(oscommands.ICmdObj) } scenarios := []scenario{ { testName: "Fetch in foreground (all=false)", fetchAllConfig: false, test: func(cmdObj oscommands.ICmdObj) { assert.True(t, cmdObj.ShouldLog()) assert.Equal(t, cmdObj.GetCredentialStrategy(), oscommands.PROMPT) assert.Equal(t, cmdObj.Args(), []string{"git", "fetch"}) }, }, { testName: "Fetch in foreground (all=true)", fetchAllConfig: true, test: func(cmdObj oscommands.ICmdObj) { assert.True(t, cmdObj.ShouldLog()) assert.Equal(t, cmdObj.GetCredentialStrategy(), oscommands.PROMPT) assert.Equal(t, cmdObj.Args(), []string{"git", "fetch", "--all"}) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildSyncCommands(commonDeps{}) instance.UserConfig().Git.FetchAll = s.fetchAllConfig task := gocui.NewFakeTask() s.test(instance.FetchCmdObj(task)) }) } } func TestSyncFetchBackground(t *testing.T) { type scenario struct { testName string fetchAllConfig bool test func(oscommands.ICmdObj) } scenarios := []scenario{ { testName: "Fetch in background (all=false)", fetchAllConfig: false, test: func(cmdObj oscommands.ICmdObj) { assert.False(t, cmdObj.ShouldLog()) assert.Equal(t, cmdObj.GetCredentialStrategy(), oscommands.FAIL) assert.Equal(t, cmdObj.Args(), []string{"git", "fetch"}) }, }, { testName: "Fetch in background (all=true)", fetchAllConfig: true, test: func(cmdObj oscommands.ICmdObj) { assert.False(t, cmdObj.ShouldLog()) assert.Equal(t, cmdObj.GetCredentialStrategy(), oscommands.FAIL) assert.Equal(t, cmdObj.Args(), []string{"git", "fetch", "--all"}) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildSyncCommands(commonDeps{}) instance.UserConfig().Git.FetchAll = s.fetchAllConfig s.test(instance.FetchBackgroundCmdObj()) }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/tag.go000066400000000000000000000026161500612110400220650ustar00rootroot00000000000000package git_commands import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) type TagCommands struct { *GitCommon } func NewTagCommands(gitCommon *GitCommon) *TagCommands { return &TagCommands{ GitCommon: gitCommon, } } func (self *TagCommands) CreateLightweightObj(tagName string, ref string, force bool) oscommands.ICmdObj { cmdArgs := NewGitCmd("tag"). ArgIf(force, "--force"). Arg("--", tagName). ArgIf(len(ref) > 0, ref). ToArgv() return self.cmd.New(cmdArgs) } func (self *TagCommands) CreateAnnotatedObj(tagName, ref, msg string, force bool) oscommands.ICmdObj { cmdArgs := NewGitCmd("tag").Arg(tagName). ArgIf(force, "--force"). ArgIf(len(ref) > 0, ref). Arg("-m", msg). ToArgv() return self.cmd.New(cmdArgs) } func (self *TagCommands) HasTag(tagName string) bool { cmdArgs := NewGitCmd("show-ref"). Arg("--tags", "--quiet", "--verify", "--"). Arg("refs/tags/" + tagName). ToArgv() return self.cmd.New(cmdArgs).Run() == nil } func (self *TagCommands) LocalDelete(tagName string) error { cmdArgs := NewGitCmd("tag").Arg("-d", tagName). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *TagCommands) Push(task gocui.Task, remoteName string, tagName string) error { cmdArgs := NewGitCmd("push").Arg(remoteName, "tag", tagName). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() } lazygit-0.50.0+ds1/pkg/commands/git_commands/tag_loader.go000066400000000000000000000024061500612110400234100ustar00rootroot00000000000000package git_commands import ( "regexp" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type TagLoader struct { *common.Common cmd oscommands.ICmdObjBuilder } func NewTagLoader( common *common.Common, cmd oscommands.ICmdObjBuilder, ) *TagLoader { return &TagLoader{ Common: common, cmd: cmd, } } func (self *TagLoader) GetTags() ([]*models.Tag, error) { // get remote branches, sorted by creation date (descending) // see: https://git-scm.com/docs/git-tag#Documentation/git-tag.txt---sortltkeygt cmdArgs := NewGitCmd("tag").Arg("--list", "-n", "--sort=-creatordate").ToArgv() tagsOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() if err != nil { return nil, err } split := utils.SplitLines(tagsOutput) lineRegex := regexp.MustCompile(`^([^\s]+)(\s+)?(.*)$`) tags := lo.Map(split, func(line string, _ int) *models.Tag { matches := lineRegex.FindStringSubmatch(line) tagName := matches[1] message := "" if len(matches) > 3 { message = matches[3] } return &models.Tag{ Name: tagName, Message: message, } }) return tags, nil } lazygit-0.50.0+ds1/pkg/commands/git_commands/tag_loader_test.go000066400000000000000000000030461500612110400244500ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) const tagsOutput = `tag1 this is my message tag2 tag3 this is my other message ` func TestGetTags(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner expectedTags []*models.Tag expectedError error } scenarios := []scenario{ { testName: "should return no tags if there are none", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"tag", "--list", "-n", "--sort=-creatordate"}, "", nil), expectedTags: []*models.Tag{}, expectedError: nil, }, { testName: "should return tags if present", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"tag", "--list", "-n", "--sort=-creatordate"}, tagsOutput, nil), expectedTags: []*models.Tag{ {Name: "tag1", Message: "this is my message"}, {Name: "tag2", Message: ""}, {Name: "tag3", Message: "this is my other message"}, }, expectedError: nil, }, } for _, scenario := range scenarios { t.Run(scenario.testName, func(t *testing.T) { loader := &TagLoader{ Common: utils.NewDummyCommon(), cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner), } tags, err := loader.GetTags() assert.Equal(t, scenario.expectedTags, tags) assert.Equal(t, scenario.expectedError, err) scenario.runner.CheckForMissingCalls() }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/version.go000066400000000000000000000035541500612110400230010ustar00rootroot00000000000000package git_commands import ( "errors" "regexp" "strconv" "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) type GitVersion struct { Major, Minor, Patch int Additional string } func GetGitVersion(osCommand *oscommands.OSCommand) (*GitVersion, error) { versionStr, _, err := osCommand.Cmd.New(NewGitCmd("--version").ToArgv()).RunWithOutputs() if err != nil { return nil, err } version, err := ParseGitVersion(versionStr) if err != nil { return nil, err } return version, nil } func ParseGitVersion(versionStr string) (*GitVersion, error) { // versionStr should be something like: // git version 2.39.0 // git version 2.37.1 (Apple Git-137.1) re := regexp.MustCompile(`[^\d]*(\d+)(\.\d+)?(\.\d+)?(.*)`) matches := re.FindStringSubmatch(versionStr) if len(matches) < 5 { return nil, errors.New("unexpected git version format: " + versionStr) } v := &GitVersion{} var err error if v.Major, err = strconv.Atoi(matches[1]); err != nil { return nil, err } if len(matches[2]) > 1 { if v.Minor, err = strconv.Atoi(matches[2][1:]); err != nil { return nil, err } } if len(matches[3]) > 1 { if v.Patch, err = strconv.Atoi(matches[3][1:]); err != nil { return nil, err } } v.Additional = strings.Trim(matches[4], " \r\n") return v, nil } func (v *GitVersion) IsOlderThan(major, minor, patch int) bool { actual := v.Major*1000*1000 + v.Minor*1000 + v.Patch required := major*1000*1000 + minor*1000 + patch return actual < required } func (v *GitVersion) IsOlderThanVersion(version *GitVersion) bool { return v.IsOlderThan(version.Major, version.Minor, version.Patch) } func (v *GitVersion) IsAtLeast(major, minor, patch int) bool { return !v.IsOlderThan(major, minor, patch) } func (v *GitVersion) IsAtLeastVersion(version *GitVersion) bool { return v.IsAtLeast(version.Major, version.Minor, version.Patch) } lazygit-0.50.0+ds1/pkg/commands/git_commands/version_test.go000066400000000000000000000032501500612110400240310ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/stretchr/testify/assert" ) func TestParseGitVersion(t *testing.T) { scenarios := []struct { input string expected GitVersion }{ { input: "git version 2.39.0", expected: GitVersion{Major: 2, Minor: 39, Patch: 0, Additional: ""}, }, { input: "git version 2.37.1 (Apple Git-137.1)", expected: GitVersion{Major: 2, Minor: 37, Patch: 1, Additional: "(Apple Git-137.1)"}, }, { input: "git version 2.37 (Apple Git-137.1)", expected: GitVersion{Major: 2, Minor: 37, Patch: 0, Additional: "(Apple Git-137.1)"}, }, } for _, s := range scenarios { actual, err := ParseGitVersion(s.input) assert.NoError(t, err) assert.NotNil(t, actual) assert.Equal(t, s.expected.Major, actual.Major) assert.Equal(t, s.expected.Minor, actual.Minor) assert.Equal(t, s.expected.Patch, actual.Patch) assert.Equal(t, s.expected.Additional, actual.Additional) } } func TestGitVersionIsOlderThan(t *testing.T) { assert.False(t, (&GitVersion{2, 0, 0, ""}).IsOlderThan(1, 99, 99)) assert.False(t, (&GitVersion{2, 0, 0, ""}).IsOlderThan(2, 0, 0)) assert.False(t, (&GitVersion{2, 1, 0, ""}).IsOlderThan(2, 0, 9)) assert.True(t, (&GitVersion{2, 0, 1, ""}).IsOlderThan(2, 1, 0)) assert.True(t, (&GitVersion{2, 0, 1, ""}).IsOlderThan(3, 0, 0)) } func TestGitVersionIsAtLeast(t *testing.T) { assert.True(t, (&GitVersion{2, 0, 0, ""}).IsAtLeast(1, 99, 99)) assert.True(t, (&GitVersion{2, 0, 0, ""}).IsAtLeast(2, 0, 0)) assert.True(t, (&GitVersion{2, 1, 0, ""}).IsAtLeast(2, 0, 9)) assert.False(t, (&GitVersion{2, 0, 1, ""}).IsAtLeast(2, 1, 0)) assert.False(t, (&GitVersion{2, 0, 1, ""}).IsAtLeast(3, 0, 0)) } lazygit-0.50.0+ds1/pkg/commands/git_commands/working_tree.go000066400000000000000000000267511500612110400240170ustar00rootroot00000000000000package git_commands import ( "fmt" "os" "path/filepath" "regexp" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) type WorkingTreeCommands struct { *GitCommon submodule *SubmoduleCommands fileLoader *FileLoader } func NewWorkingTreeCommands( gitCommon *GitCommon, submodule *SubmoduleCommands, fileLoader *FileLoader, ) *WorkingTreeCommands { return &WorkingTreeCommands{ GitCommon: gitCommon, submodule: submodule, fileLoader: fileLoader, } } func (self *WorkingTreeCommands) OpenMergeToolCmdObj() oscommands.ICmdObj { return self.cmd.New(NewGitCmd("mergetool").ToArgv()) } // StageFile stages a file func (self *WorkingTreeCommands) StageFile(path string) error { return self.StageFiles([]string{path}, nil) } func (self *WorkingTreeCommands) StageFiles(paths []string, extraArgs []string) error { cmdArgs := NewGitCmd("add"). Arg(extraArgs...). Arg("--"). Arg(paths...). ToArgv() return self.cmd.New(cmdArgs).Run() } // StageAll stages all files func (self *WorkingTreeCommands) StageAll() error { cmdArgs := NewGitCmd("add").Arg("-A").ToArgv() return self.cmd.New(cmdArgs).Run() } // UnstageAll unstages all files func (self *WorkingTreeCommands) UnstageAll() error { return self.cmd.New(NewGitCmd("reset").ToArgv()).Run() } // UnStageFile unstages a file // we accept an array of filenames for the cases where a file has been renamed i.e. // we accept the current name and the previous name func (self *WorkingTreeCommands) UnStageFile(paths []string, tracked bool) error { if tracked { return self.UnstageTrackedFiles(paths) } else { return self.UnstageUntrackedFiles(paths) } } func (self *WorkingTreeCommands) UnstageTrackedFiles(paths []string) error { return self.cmd.New(NewGitCmd("reset").Arg("HEAD", "--").Arg(paths...).ToArgv()).Run() } func (self *WorkingTreeCommands) UnstageUntrackedFiles(paths []string) error { return self.cmd.New(NewGitCmd("rm").Arg("--cached", "--force", "--").Arg(paths...).ToArgv()).Run() } func (self *WorkingTreeCommands) BeforeAndAfterFileForRename(file *models.File) (*models.File, *models.File, error) { if !file.IsRename() { return nil, nil, errors.New("Expected renamed file") } // we've got a file that represents a rename from one file to another. Here we will refetch // all files, passing the --no-renames flag and then recursively call the function // again for the before file and after file. filesWithoutRenames := self.fileLoader.GetStatusFiles(GetStatusFileOptions{NoRenames: true}) var beforeFile *models.File var afterFile *models.File for _, f := range filesWithoutRenames { if f.Path == file.PreviousPath { beforeFile = f } if f.Path == file.Path { afterFile = f } } if beforeFile == nil || afterFile == nil { return nil, nil, errors.New("Could not find deleted file or new file for file rename") } if beforeFile.IsRename() || afterFile.IsRename() { // probably won't happen but we want to ensure we don't get an infinite loop return nil, nil, errors.New("Nested rename found") } return beforeFile, afterFile, nil } // DiscardAllFileChanges directly func (self *WorkingTreeCommands) DiscardAllFileChanges(file *models.File) error { if file.IsRename() { beforeFile, afterFile, err := self.BeforeAndAfterFileForRename(file) if err != nil { return err } if err := self.DiscardAllFileChanges(beforeFile); err != nil { return err } if err := self.DiscardAllFileChanges(afterFile); err != nil { return err } return nil } if file.ShortStatus == "AA" { if err := self.cmd.New( NewGitCmd("checkout").Arg("--ours", "--", file.Path).ToArgv(), ).Run(); err != nil { return err } if err := self.cmd.New( NewGitCmd("add").Arg("--", file.Path).ToArgv(), ).Run(); err != nil { return err } return nil } if file.ShortStatus == "DU" { return self.cmd.New( NewGitCmd("rm").Arg("--", file.Path).ToArgv(), ).Run() } // if the file isn't tracked, we assume you want to delete it if file.HasStagedChanges || file.HasMergeConflicts { if err := self.cmd.New( NewGitCmd("reset").Arg("--", file.Path).ToArgv(), ).Run(); err != nil { return err } } if file.ShortStatus == "DD" || file.ShortStatus == "AU" { return nil } if file.Added { return self.os.RemoveFile(file.Path) } return self.DiscardUnstagedFileChanges(file) } type IFileNode interface { ForEachFile(cb func(*models.File) error) error GetFilePathsMatching(test func(*models.File) bool) []string GetPath() string // Returns file if the node is not a directory, otherwise returns nil GetFile() *models.File } func (self *WorkingTreeCommands) DiscardAllDirChanges(node IFileNode) error { // this could be more efficient but we would need to handle all the edge cases return node.ForEachFile(self.DiscardAllFileChanges) } func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error { file := node.GetFile() if file == nil { if err := self.RemoveUntrackedDirFiles(node); err != nil { return err } cmdArgs := NewGitCmd("checkout").Arg("--", node.GetPath()).ToArgv() if err := self.cmd.New(cmdArgs).Run(); err != nil { return err } } else { if file.Added && !file.HasStagedChanges { return self.os.RemoveFile(file.Path) } if err := self.DiscardUnstagedFileChanges(file); err != nil { return err } } return nil } func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error { untrackedFilePaths := node.GetFilePathsMatching( func(file *models.File) bool { return !file.GetIsTracked() }, ) for _, path := range untrackedFilePaths { err := os.Remove(path) if err != nil { return err } } return nil } func (self *WorkingTreeCommands) DiscardUnstagedFileChanges(file *models.File) error { cmdArgs := NewGitCmd("checkout").Arg("--", file.Path).ToArgv() return self.cmd.New(cmdArgs).Run() } // Escapes special characters in a filename for gitignore and exclude files func escapeFilename(filename string) string { re := regexp.MustCompile(`^[!#]|[\[\]*]`) return re.ReplaceAllString(filename, `\${0}`) } // Ignore adds a file to the gitignore for the repo func (self *WorkingTreeCommands) Ignore(filename string) error { return self.os.AppendLineToFile(".gitignore", escapeFilename(filename)) } // Exclude adds a file to the .git/info/exclude for the repo func (self *WorkingTreeCommands) Exclude(filename string) error { excludeFile := filepath.Join(self.repoPaths.repoGitDirPath, "info", "exclude") return self.os.AppendLineToFile(excludeFile, escapeFilename(filename)) } // WorktreeFileDiff returns the diff of a file func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string { // for now we assume an error means the file was deleted s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached).RunWithOutput() return s } func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool) oscommands.ICmdObj { colorArg := self.UserConfig().Git.Paging.ColorArg if plain { colorArg = "never" } contextSize := self.AppState.DiffContextSize prevPath := node.GetPreviousPath() noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile() extDiffCmd := self.UserConfig().Git.Paging.ExternalDiffCommand useExtDiff := extDiffCmd != "" && !plain cmdArgs := NewGitCmd("diff"). ConfigIf(useExtDiff, "diff.external="+extDiffCmd). ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff"). Arg("--submodule"). Arg(fmt.Sprintf("--unified=%d", contextSize)). Arg(fmt.Sprintf("--color=%s", colorArg)). ArgIf(!plain && self.AppState.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg(fmt.Sprintf("--find-renames=%d%%", self.AppState.RenameSimilarityThreshold)). ArgIf(cached, "--cached"). ArgIf(noIndex, "--no-index"). Arg("--"). ArgIf(noIndex, "/dev/null"). Arg(node.GetPath()). ArgIf(prevPath != "", prevPath). Dir(self.repoPaths.worktreePath). ToArgv() return self.cmd.New(cmdArgs).DontLog() } // ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc // but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, plain bool) (string, error) { return self.ShowFileDiffCmdObj(from, to, reverse, fileName, plain).RunWithOutput() } func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileName string, plain bool) oscommands.ICmdObj { contextSize := self.AppState.DiffContextSize colorArg := self.UserConfig().Git.Paging.ColorArg if plain { colorArg = "never" } extDiffCmd := self.UserConfig().Git.Paging.ExternalDiffCommand useExtDiff := extDiffCmd != "" && !plain cmdArgs := NewGitCmd("diff"). Config("diff.noprefix=false"). ConfigIf(useExtDiff, "diff.external="+extDiffCmd). ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff"). Arg("--submodule"). Arg(fmt.Sprintf("--unified=%d", contextSize)). Arg("--no-renames"). Arg(fmt.Sprintf("--color=%s", colorArg)). Arg(from). Arg(to). ArgIf(reverse, "-R"). ArgIf(!plain && self.AppState.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg("--"). Arg(fileName). Dir(self.repoPaths.worktreePath). ToArgv() return self.cmd.New(cmdArgs).DontLog() } // CheckoutFile checks out the file for the given commit func (self *WorkingTreeCommands) CheckoutFile(commitHash, fileName string) error { cmdArgs := NewGitCmd("checkout").Arg(commitHash, "--", fileName). ToArgv() return self.cmd.New(cmdArgs).Run() } // DiscardAnyUnstagedFileChanges discards any unstaged file changes via `git checkout -- .` func (self *WorkingTreeCommands) DiscardAnyUnstagedFileChanges() error { cmdArgs := NewGitCmd("checkout").Arg("--", "."). ToArgv() return self.cmd.New(cmdArgs).Run() } // RemoveTrackedFiles will delete the given file(s) even if they are currently tracked func (self *WorkingTreeCommands) RemoveTrackedFiles(name string) error { cmdArgs := NewGitCmd("rm").Arg("-r", "--cached", "--", name). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *WorkingTreeCommands) RemoveConflictedFile(name string) error { cmdArgs := NewGitCmd("rm").Arg("--", name). ToArgv() return self.cmd.New(cmdArgs).Run() } // RemoveUntrackedFiles runs `git clean -fd` func (self *WorkingTreeCommands) RemoveUntrackedFiles() error { cmdArgs := NewGitCmd("clean").Arg("-fd").ToArgv() return self.cmd.New(cmdArgs).Run() } // ResetAndClean removes all unstaged changes and removes all untracked files func (self *WorkingTreeCommands) ResetAndClean() error { submoduleConfigs, err := self.submodule.GetConfigs(nil) if err != nil { return err } if len(submoduleConfigs) > 0 { if err := self.submodule.ResetSubmodules(submoduleConfigs); err != nil { return err } } if err := self.ResetHard("HEAD"); err != nil { return err } return self.RemoveUntrackedFiles() } // ResetHard runs `git reset --hard` func (self *WorkingTreeCommands) ResetHard(ref string) error { cmdArgs := NewGitCmd("reset").Arg("--hard", ref). ToArgv() return self.cmd.New(cmdArgs).Run() } // ResetSoft runs `git reset --soft HEAD` func (self *WorkingTreeCommands) ResetSoft(ref string) error { cmdArgs := NewGitCmd("reset").Arg("--soft", ref). ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *WorkingTreeCommands) ResetMixed(ref string) error { cmdArgs := NewGitCmd("reset").Arg("--mixed", ref). ToArgv() return self.cmd.New(cmdArgs).Run() } lazygit-0.50.0+ds1/pkg/commands/git_commands/working_tree_test.go000066400000000000000000000411211500612110400250420ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/stretchr/testify/assert" ) func TestWorkingTreeStageFile(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"add", "--", "test.txt"}, "", nil) instance := buildWorkingTreeCommands(commonDeps{runner: runner}) assert.NoError(t, instance.StageFile("test.txt")) runner.CheckForMissingCalls() } func TestWorkingTreeStageFiles(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"add", "--", "test.txt", "test2.txt"}, "", nil) instance := buildWorkingTreeCommands(commonDeps{runner: runner}) assert.NoError(t, instance.StageFiles([]string{"test.txt", "test2.txt"}, nil)) runner.CheckForMissingCalls() } func TestWorkingTreeUnstageFile(t *testing.T) { type scenario struct { testName string reset bool runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "Remove an untracked file from staging", reset: false, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rm", "--cached", "--force", "--", "test.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { testName: "Remove a tracked file from staging", reset: true, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "HEAD", "--", "test.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.UnStageFile([]string{"test.txt"}, s.reset)) }) } } // these tests don't cover everything, in part because we already have an integration // test which does cover everything. I don't want to unnecessarily assert on the 'how' // when the 'what' is what matters func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { type scenario struct { testName string file *models.File removeFile func(string) error runner *oscommands.FakeCmdObjRunner expectedError string } scenarios := []scenario{ { testName: "An error occurred when resetting", file: &models.File{ Path: "test", HasStagedChanges: true, }, removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", errors.New("error")), expectedError: "error", }, { testName: "An error occurred when removing file", file: &models.File{ Path: "test", Tracked: false, Added: true, }, removeFile: func(string) error { return errors.New("an error occurred when removing file") }, runner: oscommands.NewFakeRunner(t), expectedError: "an error occurred when removing file", }, { testName: "An error occurred with checkout", file: &models.File{ Path: "test", Tracked: true, HasStagedChanges: false, }, removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "test"}, "", errors.New("error")), expectedError: "error", }, { testName: "Checkout only", file: &models.File{ Path: "test", Tracked: true, HasStagedChanges: false, }, removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), expectedError: "", }, { testName: "Reset and checkout staged changes", file: &models.File{ Path: "test", Tracked: true, HasStagedChanges: true, }, removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), expectedError: "", }, { testName: "Reset and checkout merge conflicts", file: &models.File{ Path: "test", Tracked: true, HasMergeConflicts: true, }, removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), expectedError: "", }, { testName: "Reset and remove", file: &models.File{ Path: "test", Tracked: false, Added: true, HasStagedChanges: true, }, removeFile: func(filename string) error { assert.Equal(t, "test", filename) return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil), expectedError: "", }, { testName: "Remove only", file: &models.File{ Path: "test", Tracked: false, Added: true, HasStagedChanges: false, }, removeFile: func(filename string) error { assert.Equal(t, "test", filename) return nil }, runner: oscommands.NewFakeRunner(t), expectedError: "", }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: s.removeFile}) err := instance.DiscardAllFileChanges(s.file) if s.expectedError == "" { assert.Nil(t, err) } else { assert.Equal(t, s.expectedError, err.Error()) } s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeDiff(t *testing.T) { type scenario struct { testName string file *models.File plain bool cached bool ignoreWhitespace bool contextSize uint64 similarityThreshold int runner *oscommands.FakeCmdObjRunner } const expectedResult = "pretend this is an actual git diff" scenarios := []scenario{ { testName: "Default case", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: false, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil), }, { testName: "cached", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: true, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--cached", "--", "test.txt"}, expectedResult, nil), }, { testName: "plain", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: true, cached: false, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=never", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil), }, { testName: "File not tracked and file has no staged changes", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: false, }, plain: false, cached: false, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--no-index", "--", "/dev/null", "test.txt"}, expectedResult, nil), }, { testName: "Default case (ignore whitespace)", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: false, ignoreWhitespace: true, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--ignore-all-space", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom context size", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: false, ignoreWhitespace: false, contextSize: 17, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=17", "--color=always", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom similarity threshold", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: false, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 33, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=33%", "--", "test.txt"}, expectedResult, nil), }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() appState := &config.AppState{} appState.IgnoreWhitespaceInDiffView = s.ignoreWhitespace appState.DiffContextSize = s.contextSize appState.RenameSimilarityThreshold = s.similarityThreshold repoPaths := RepoPaths{ worktreePath: "/path/to/worktree", } instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: appState, repoPaths: &repoPaths}) result := instance.WorktreeFileDiff(s.file, s.plain, s.cached) assert.Equal(t, expectedResult, result) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeShowFileDiff(t *testing.T) { type scenario struct { testName string from string to string reverse bool plain bool ignoreWhitespace bool contextSize uint64 runner *oscommands.FakeCmdObjRunner } const expectedResult = "pretend this is an actual git diff" scenarios := []scenario{ { testName: "Default case", from: "1234567890", to: "0987654321", reverse: false, plain: false, ignoreWhitespace: false, contextSize: 3, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--no-renames", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom context size", from: "1234567890", to: "0987654321", reverse: false, plain: false, ignoreWhitespace: false, contextSize: 123, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=123", "--no-renames", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), }, { testName: "Default case (ignore whitespace)", from: "1234567890", to: "0987654321", reverse: false, plain: false, ignoreWhitespace: true, contextSize: 3, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--no-renames", "--color=always", "1234567890", "0987654321", "--ignore-all-space", "--", "test.txt"}, expectedResult, nil), }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() appState := &config.AppState{} appState.IgnoreWhitespaceInDiffView = s.ignoreWhitespace appState.DiffContextSize = s.contextSize repoPaths := RepoPaths{ worktreePath: "/path/to/worktree", } instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: appState, repoPaths: &repoPaths}) result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, "test.txt", s.plain) assert.NoError(t, err) assert.Equal(t, expectedResult, result) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeCheckoutFile(t *testing.T) { type scenario struct { testName string commitHash string fileName string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "typical case", commitHash: "11af912", fileName: "test999.txt", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "11af912", "--", "test999.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { testName: "returns error if there is one", commitHash: "11af912", fileName: "test999.txt", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "11af912", "--", "test999.txt"}, "", errors.New("error")), test: func(err error) { assert.Error(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.CheckoutFile(s.commitHash, s.fileName)) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeDiscardUnstagedFileChanges(t *testing.T) { type scenario struct { testName string file *models.File runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "valid case", file: &models.File{Path: "test.txt"}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "test.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.DiscardUnstagedFileChanges(s.file)) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeDiscardAnyUnstagedFileChanges(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "valid case", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "."}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.DiscardAnyUnstagedFileChanges()) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeRemoveUntrackedFiles(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "valid case", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"clean", "-fd"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.RemoveUntrackedFiles()) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeResetHard(t *testing.T) { type scenario struct { testName string ref string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { "valid case", "HEAD", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--hard", "HEAD"}, "", nil), func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.ResetHard(s.ref)) }) } } lazygit-0.50.0+ds1/pkg/commands/git_commands/worktree.go000066400000000000000000000033641500612110400231550ustar00rootroot00000000000000package git_commands import ( "path/filepath" "github.com/jesseduffield/lazygit/pkg/commands/models" ) type WorktreeCommands struct { *GitCommon } func NewWorktreeCommands(gitCommon *GitCommon) *WorktreeCommands { return &WorktreeCommands{ GitCommon: gitCommon, } } type NewWorktreeOpts struct { // required. The path of the new worktree. Path string // required. The base branch/ref. Base string // if true, ends up with a detached head Detach bool // optional. if empty, and if detach is false, we will checkout the base Branch string } func (self *WorktreeCommands) New(opts NewWorktreeOpts) error { if opts.Detach && opts.Branch != "" { panic("cannot specify branch when detaching") } cmdArgs := NewGitCmd("worktree").Arg("add"). ArgIf(opts.Detach, "--detach"). ArgIf(opts.Branch != "", "-b", opts.Branch). Arg(opts.Path, opts.Base) return self.cmd.New(cmdArgs.ToArgv()).Run() } func (self *WorktreeCommands) Delete(worktreePath string, force bool) error { cmdArgs := NewGitCmd("worktree").Arg("remove").ArgIf(force, "-f").Arg(worktreePath).ToArgv() return self.cmd.New(cmdArgs).Run() } func (self *WorktreeCommands) Detach(worktreePath string) error { cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv() return self.cmd.New(cmdArgs).Run() } func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) { for _, worktree := range worktrees { if worktree.Branch == branch.Name { return worktree, true } } return nil, false } func CheckedOutByOtherWorktree(branch *models.Branch, worktrees []*models.Worktree) bool { worktree, ok := WorktreeForBranch(branch, worktrees) if !ok { return false } return !worktree.IsCurrent } lazygit-0.50.0+ds1/pkg/commands/git_commands/worktree_loader.go000066400000000000000000000164771500612110400245140ustar00rootroot00000000000000package git_commands import ( iofs "io/fs" "path/filepath" "strings" "sync" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/spf13/afero" ) type WorktreeLoader struct { *GitCommon } func NewWorktreeLoader(gitCommon *GitCommon) *WorktreeLoader { return &WorktreeLoader{GitCommon: gitCommon} } func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { currentRepoPath := self.repoPaths.RepoPath() worktreePath := self.repoPaths.WorktreePath() cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv() worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() if err != nil { return nil, err } splitLines := strings.Split( utils.NormalizeLinefeeds(worktreesOutput), "\n", ) var worktrees []*models.Worktree var current *models.Worktree for _, splitLine := range splitLines { // worktrees are defined over multiple lines and are separated by blank lines // so if we reach a blank line we're done with the current worktree if len(splitLine) == 0 && current != nil { worktrees = append(worktrees, current) current = nil continue } // ignore bare repo (not sure why it's even appearing in this list: it's not a worktree) if splitLine == "bare" { current = nil continue } if strings.HasPrefix(splitLine, "worktree ") { path := strings.SplitN(splitLine, " ", 2)[1] isMain := path == currentRepoPath isCurrent := path == worktreePath isPathMissing := self.pathExists(path) current = &models.Worktree{ IsMain: isMain, IsCurrent: isCurrent, IsPathMissing: isPathMissing, Path: path, // we defer populating GitDir until a loop below so that // we can parallelize the calls to git rev-parse GitDir: "", } } else if strings.HasPrefix(splitLine, "branch ") { branch := strings.SplitN(splitLine, " ", 2)[1] current.Branch = strings.TrimPrefix(branch, "refs/heads/") } } wg := sync.WaitGroup{} wg.Add(len(worktrees)) for _, worktree := range worktrees { go utils.Safe(func() { defer wg.Done() if worktree.IsPathMissing { return } gitDir, err := callGitRevParseWithDir(self.cmd, self.version, worktree.Path, "--absolute-git-dir") if err != nil { self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err) return } worktree.GitDir = gitDir }) } wg.Wait() names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string { return worktree.Path })) for index, worktree := range worktrees { worktree.Name = names[index] } // move current worktree to the top for i, worktree := range worktrees { if worktree.IsCurrent { worktrees = append(worktrees[:i], worktrees[i+1:]...) worktrees = append([]*models.Worktree{worktree}, worktrees...) break } } // Some worktrees are on a branch but are mid-rebase, and in those cases, // `git worktree list` will not show the branch name. We can get the branch // name from the `rebase-merge/head-name` file (if it exists) in the folder // for the worktree in the parent repo's .git/worktrees folder. for _, worktree := range worktrees { // No point checking if we already have a branch name if worktree.Branch != "" { continue } // If we couldn't find the git directory, we can't find the branch name if worktree.GitDir == "" { continue } rebasedBranch, ok := self.rebasedBranch(worktree) if ok { worktree.Branch = rebasedBranch continue } bisectedBranch, ok := self.bisectedBranch(worktree) if ok { worktree.Branch = bisectedBranch continue } } return worktrees, nil } func (self *WorktreeLoader) pathExists(path string) bool { if _, err := self.Fs.Stat(path); err != nil { if errors.Is(err, iofs.ErrNotExist) { return true } self.Log.Errorf("failed to check if worktree path `%s` exists\n%v", path, err) return false } return false } func (self *WorktreeLoader) rebasedBranch(worktree *models.Worktree) (string, bool) { for _, dir := range []string{"rebase-merge", "rebase-apply"} { if bytesContent, err := afero.ReadFile(self.Fs, filepath.Join(worktree.GitDir, dir, "head-name")); err == nil { headName := strings.TrimSpace(string(bytesContent)) shortHeadName := strings.TrimPrefix(headName, "refs/heads/") return shortHeadName, true } } return "", false } func (self *WorktreeLoader) bisectedBranch(worktree *models.Worktree) (string, bool) { bisectStartPath := filepath.Join(worktree.GitDir, "BISECT_START") startContent, err := afero.ReadFile(self.Fs, bisectStartPath) if err != nil { return "", false } return strings.TrimSpace(string(startContent)), true } type pathWithIndexT struct { path string index int } type nameWithIndexT struct { name string index int } func getUniqueNamesFromPaths(paths []string) []string { pathsWithIndex := lo.Map(paths, func(path string, index int) pathWithIndexT { return pathWithIndexT{path, index} }) namesWithIndex := getUniqueNamesFromPathsAux(pathsWithIndex, 0) // now sort based on index result := make([]string, len(namesWithIndex)) for _, nameWithIndex := range namesWithIndex { result[nameWithIndex.index] = nameWithIndex.name } return result } func getUniqueNamesFromPathsAux(paths []pathWithIndexT, depth int) []nameWithIndexT { // If we have no paths, return an empty array if len(paths) == 0 { return []nameWithIndexT{} } // If we have only one path, return the last segment of the path if len(paths) == 1 { path := paths[0] return []nameWithIndexT{{index: path.index, name: sliceAtDepth(path.path, depth)}} } // group the paths by their value at the specified depth groups := make(map[string][]pathWithIndexT) for _, path := range paths { value := valueAtDepth(path.path, depth) groups[value] = append(groups[value], path) } result := []nameWithIndexT{} for _, group := range groups { if len(group) == 1 { path := group[0] result = append(result, nameWithIndexT{index: path.index, name: sliceAtDepth(path.path, depth)}) } else { result = append(result, getUniqueNamesFromPathsAux(group, depth+1)...) } } return result } // if the path is /a/b/c/d, and the depth is 0, the value is 'd'. If the depth is 1, the value is 'c', etc func valueAtDepth(path string, depth int) string { path = strings.TrimPrefix(path, "/") path = strings.TrimSuffix(path, "/") // Split the path into segments segments := strings.Split(path, "/") // Get the length of segments length := len(segments) // If the depth is greater than the length of segments, return an empty string if depth >= length { return "" } // Return the segment at the specified depth from the end of the path return segments[length-1-depth] } // if the path is /a/b/c/d, and the depth is 0, the value is 'd'. If the depth is 1, the value is 'b/c', etc func sliceAtDepth(path string, depth int) string { path = strings.TrimPrefix(path, "/") path = strings.TrimSuffix(path, "/") // Split the path into segments segments := strings.Split(path, "/") // Get the length of segments length := len(segments) // If the depth is greater than or equal to the length of segments, return an empty string if depth >= length { return "" } // Join the segments from the specified depth till end of the path return strings.Join(segments[length-1-depth:], "/") } lazygit-0.50.0+ds1/pkg/commands/git_commands/worktree_loader_test.go000066400000000000000000000167051500612110400255450ustar00rootroot00000000000000package git_commands import ( "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/spf13/afero" "github.com/stretchr/testify/assert" ) func TestGetWorktrees(t *testing.T) { type scenario struct { testName string repoPaths *RepoPaths before func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) expectedWorktrees []*models.Worktree expectedErr string } scenarios := []scenario{ { testName: "Single worktree (main)", repoPaths: &RepoPaths{ repoPath: "/path/to/repo", worktreePath: "/path/to/repo", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, `worktree /path/to/repo HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d branch refs/heads/mybranch `, nil) gitArgsMainWorktree := append(append([]string{"-C", "/path/to/repo"}, getRevParseArgs()...), "--absolute-git-dir") runner.ExpectGitArgs(gitArgsMainWorktree, "/path/to/repo/.git", nil) _ = fs.MkdirAll("/path/to/repo/.git", 0o755) }, expectedWorktrees: []*models.Worktree{ { IsMain: true, IsCurrent: true, Path: "/path/to/repo", IsPathMissing: false, GitDir: "/path/to/repo/.git", Branch: "mybranch", Name: "repo", }, }, expectedErr: "", }, { testName: "Multiple worktrees (main + linked)", repoPaths: &RepoPaths{ repoPath: "/path/to/repo", worktreePath: "/path/to/repo", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, `worktree /path/to/repo HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d branch refs/heads/mybranch worktree /path/to/repo-worktree HEAD 775955775e79b8f5b4c4b56f82fbf657e2d5e4de branch refs/heads/mybranch-worktree `, nil) gitArgsMainWorktree := append(append([]string{"-C", "/path/to/repo"}, getRevParseArgs()...), "--absolute-git-dir") runner.ExpectGitArgs(gitArgsMainWorktree, "/path/to/repo/.git", nil) gitArgsLinkedWorktree := append(append([]string{"-C", "/path/to/repo-worktree"}, getRevParseArgs()...), "--absolute-git-dir") runner.ExpectGitArgs(gitArgsLinkedWorktree, "/path/to/repo/.git/worktrees/repo-worktree", nil) _ = fs.MkdirAll("/path/to/repo/.git", 0o755) _ = fs.MkdirAll("/path/to/repo-worktree", 0o755) _ = fs.MkdirAll("/path/to/repo/.git/worktrees/repo-worktree", 0o755) _ = afero.WriteFile(fs, "/path/to/repo-worktree/.git", []byte("gitdir: /path/to/repo/.git/worktrees/repo-worktree"), 0o755) }, expectedWorktrees: []*models.Worktree{ { IsMain: true, IsCurrent: true, Path: "/path/to/repo", IsPathMissing: false, GitDir: "/path/to/repo/.git", Branch: "mybranch", Name: "repo", }, { IsMain: false, IsCurrent: false, Path: "/path/to/repo-worktree", IsPathMissing: false, GitDir: "/path/to/repo/.git/worktrees/repo-worktree", Branch: "mybranch-worktree", Name: "repo-worktree", }, }, expectedErr: "", }, { testName: "Worktree missing path", repoPaths: &RepoPaths{ repoPath: "/path/to/repo", worktreePath: "/path/to/repo", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, `worktree /path/to/worktree HEAD 775955775e79b8f5b4c4b56f82fbf657e2d5e4de branch refs/heads/missingbranch `, nil) _ = fs.MkdirAll("/path/to/repo/.git", 0o755) }, expectedWorktrees: []*models.Worktree{ { IsMain: false, IsCurrent: false, Path: "/path/to/worktree", IsPathMissing: true, GitDir: "", Branch: "missingbranch", Name: "worktree", }, }, expectedErr: "", }, { testName: "In linked worktree", repoPaths: &RepoPaths{ repoPath: "/path/to/repo", worktreePath: "/path/to/repo-worktree", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, `worktree /path/to/repo HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d branch refs/heads/mybranch worktree /path/to/repo-worktree HEAD 775955775e79b8f5b4c4b56f82fbf657e2d5e4de branch refs/heads/mybranch-worktree `, nil) gitArgsMainWorktree := append(append([]string{"-C", "/path/to/repo"}, getRevParseArgs()...), "--absolute-git-dir") runner.ExpectGitArgs(gitArgsMainWorktree, "/path/to/repo/.git", nil) gitArgsLinkedWorktree := append(append([]string{"-C", "/path/to/repo-worktree"}, getRevParseArgs()...), "--absolute-git-dir") runner.ExpectGitArgs(gitArgsLinkedWorktree, "/path/to/repo/.git/worktrees/repo-worktree", nil) _ = fs.MkdirAll("/path/to/repo/.git", 0o755) _ = fs.MkdirAll("/path/to/repo-worktree", 0o755) _ = fs.MkdirAll("/path/to/repo/.git/worktrees/repo-worktree", 0o755) _ = afero.WriteFile(fs, "/path/to/repo-worktree/.git", []byte("gitdir: /path/to/repo/.git/worktrees/repo-worktree"), 0o755) }, expectedWorktrees: []*models.Worktree{ { IsMain: false, IsCurrent: true, Path: "/path/to/repo-worktree", IsPathMissing: false, GitDir: "/path/to/repo/.git/worktrees/repo-worktree", Branch: "mybranch-worktree", Name: "repo-worktree", }, { IsMain: true, IsCurrent: false, Path: "/path/to/repo", IsPathMissing: false, GitDir: "/path/to/repo/.git", Branch: "mybranch", Name: "repo", }, }, expectedErr: "", }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { runner := oscommands.NewFakeRunner(t) fs := afero.NewMemMapFs() version, err := GetGitVersion(oscommands.NewDummyOSCommand()) if err != nil { t.Fatal(err) } getRevParseArgs := func() []string { args := []string{"rev-parse"} if version.IsAtLeast(2, 31, 0) { args = append(args, "--path-format=absolute") } return args } s.before(runner, fs, getRevParseArgs) loader := &WorktreeLoader{ GitCommon: buildGitCommon(commonDeps{runner: runner, fs: fs, repoPaths: s.repoPaths, gitVersion: version}), } worktrees, err := loader.GetWorktrees() if s.expectedErr != "" { assert.EqualError(t, errors.New(s.expectedErr), err.Error()) } else { assert.NoError(t, err) assert.EqualValues(t, s.expectedWorktrees, worktrees) } }) } } func TestGetUniqueNamesFromPaths(t *testing.T) { for _, scenario := range []struct { input []string expected []string }{ { input: []string{}, expected: []string{}, }, { input: []string{ "/my/path/feature/one", }, expected: []string{ "one", }, }, { input: []string{ "/my/path/feature/one/", }, expected: []string{ "one", }, }, { input: []string{ "/a/b/c/d", "/a/b/c/e", "/a/b/f/d", "/a/e/c/d", }, expected: []string{ "b/c/d", "e", "f/d", "e/c/d", }, }, } { actual := getUniqueNamesFromPaths(scenario.input) assert.EqualValues(t, scenario.expected, actual) } } lazygit-0.50.0+ds1/pkg/commands/git_config/000077500000000000000000000000001500612110400204225ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/git_config/cached_git_config.go000066400000000000000000000050141500612110400243500ustar00rootroot00000000000000package git_config import ( "os/exec" "strings" "sync" "github.com/sirupsen/logrus" ) type IGitConfig interface { // this is for when you want to pass 'mykey' (it calls `git config --get --null mykey` under the hood) Get(string) string // this is for when you want to pass '--local --get-regexp mykey' GetGeneral(string) string // this is for when you want to pass 'mykey' and check if the result is truthy GetBool(string) bool DropCache() } type CachedGitConfig struct { cache map[string]string runGitConfigCmd func(*exec.Cmd) (string, error) log *logrus.Entry mutex sync.Mutex } func NewStdCachedGitConfig(log *logrus.Entry) *CachedGitConfig { return NewCachedGitConfig(runGitConfigCmd, log) } func NewCachedGitConfig(runGitConfigCmd func(*exec.Cmd) (string, error), log *logrus.Entry) *CachedGitConfig { return &CachedGitConfig{ cache: make(map[string]string), runGitConfigCmd: runGitConfigCmd, log: log, mutex: sync.Mutex{}, } } func (self *CachedGitConfig) Get(key string) string { self.mutex.Lock() defer self.mutex.Unlock() if value, ok := self.cache[key]; ok { self.log.Debug("using cache for key " + key) return value } value := self.getAux(key) self.cache[key] = value return value } func (self *CachedGitConfig) GetGeneral(args string) string { self.mutex.Lock() defer self.mutex.Unlock() if value, ok := self.cache[args]; ok { self.log.Debug("using cache for args " + args) return value } value := self.getGeneralAux(args) self.cache[args] = value return value } func (self *CachedGitConfig) getGeneralAux(args string) string { cmd := getGitConfigGeneralCmd(args) value, err := self.runGitConfigCmd(cmd) if err != nil { self.log.Debugf("Error getting git config value for args: %s. Error: %v", args, err.Error()) return "" } return strings.TrimSpace(value) } func (self *CachedGitConfig) getAux(key string) string { cmd := getGitConfigCmd(key) value, err := self.runGitConfigCmd(cmd) if err != nil { self.log.Debugf("Error getting git config value for key: %s. Error: %v", key, err.Error()) return "" } return strings.TrimSpace(value) } func (self *CachedGitConfig) GetBool(key string) bool { return isTruthy(self.Get(key)) } func isTruthy(value string) bool { lcValue := strings.ToLower(value) return lcValue == "true" || lcValue == "1" || lcValue == "yes" || lcValue == "on" } func (self *CachedGitConfig) DropCache() { self.mutex.Lock() defer self.mutex.Unlock() self.cache = make(map[string]string) } lazygit-0.50.0+ds1/pkg/commands/git_config/cached_git_config_test.go000066400000000000000000000050051500612110400254070ustar00rootroot00000000000000package git_config import ( "os/exec" "strings" "testing" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) func TestGetBool(t *testing.T) { type scenario struct { testName string mockResponses map[string]string expected bool } scenarios := []scenario{ { "Option global and local config commit.gpgsign is not set", map[string]string{}, false, }, { "Some other random key is set", map[string]string{"blah": "blah"}, false, }, { "Option commit.gpgsign is true", map[string]string{"commit.gpgsign": "True"}, true, }, { "Option commit.gpgsign is on", map[string]string{"commit.gpgsign": "ON"}, true, }, { "Option commit.gpgsign is yes", map[string]string{"commit.gpgsign": "YeS"}, true, }, { "Option commit.gpgsign is 1", map[string]string{"commit.gpgsign": "1"}, true, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { fake := NewFakeGitConfig(s.mockResponses) real := NewCachedGitConfig( func(cmd *exec.Cmd) (string, error) { assert.Equal(t, "config --get --null commit.gpgsign", strings.Join(cmd.Args[1:], " ")) return fake.Get("commit.gpgsign"), nil }, utils.NewDummyLog(), ) result := real.GetBool("commit.gpgsign") assert.Equal(t, s.expected, result) }) } } func TestGet(t *testing.T) { type scenario struct { testName string mockResponses map[string]string expected string } scenarios := []scenario{ { "not set", map[string]string{}, "", }, { "is set", map[string]string{"commit.gpgsign": "blah"}, "blah", }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { fake := NewFakeGitConfig(s.mockResponses) real := NewCachedGitConfig( func(cmd *exec.Cmd) (string, error) { assert.Equal(t, "config --get --null commit.gpgsign", strings.Join(cmd.Args[1:], " ")) return fake.Get("commit.gpgsign"), nil }, utils.NewDummyLog(), ) result := real.Get("commit.gpgsign") assert.Equal(t, s.expected, result) }) } // verifying that the cache is used count := 0 real := NewCachedGitConfig( func(cmd *exec.Cmd) (string, error) { count++ assert.Equal(t, "config --get --null commit.gpgsign", strings.Join(cmd.Args[1:], " ")) return "blah", nil }, utils.NewDummyLog(), ) result := real.Get("commit.gpgsign") assert.Equal(t, "blah", result) result = real.Get("commit.gpgsign") assert.Equal(t, "blah", result) assert.Equal(t, 1, count) } lazygit-0.50.0+ds1/pkg/commands/git_config/fake_git_config.go000066400000000000000000000011631500612110400240500ustar00rootroot00000000000000package git_config type FakeGitConfig struct { mockResponses map[string]string } func NewFakeGitConfig(mockResponses map[string]string) *FakeGitConfig { return &FakeGitConfig{ mockResponses: mockResponses, } } func (self *FakeGitConfig) Get(key string) string { if self.mockResponses == nil { return "" } return self.mockResponses[key] } func (self *FakeGitConfig) GetGeneral(args string) string { if self.mockResponses == nil { return "" } return self.mockResponses[args] } func (self *FakeGitConfig) GetBool(key string) bool { return isTruthy(self.Get(key)) } func (self *FakeGitConfig) DropCache() { } lazygit-0.50.0+ds1/pkg/commands/git_config/get_key.go000066400000000000000000000040441500612110400224020ustar00rootroot00000000000000package git_config import ( "bytes" "fmt" "io" "os/exec" "strings" "syscall" ) // including license from https://github.com/tcnksm/go-gitconfig because this file is an adaptation of that repo's code // Copyright (c) 2014 tcnksm // MIT License // 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. func runGitConfigCmd(cmd *exec.Cmd) (string, error) { var stdout bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = io.Discard err := cmd.Run() if exitError, ok := err.(*exec.ExitError); ok { if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok { if waitStatus.ExitStatus() == 1 { return "", fmt.Errorf("the key is not found for %s", cmd.Args) } } return "", err } return strings.TrimRight(stdout.String(), "\000"), nil } func getGitConfigCmd(key string) *exec.Cmd { gitArgs := []string{"config", "--get", "--null", key} return exec.Command("git", gitArgs...) } func getGitConfigGeneralCmd(args string) *exec.Cmd { gitArgs := append([]string{"config"}, strings.Split(args, " ")...) return exec.Command("git", gitArgs...) } lazygit-0.50.0+ds1/pkg/commands/hosting_service/000077500000000000000000000000001500612110400215055ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/hosting_service/definitions.go000066400000000000000000000104741500612110400243550ustar00rootroot00000000000000package hosting_service // if you want to make a custom regex for a given service feel free to test it out // at regoio.herokuapp.com var defaultUrlRegexStrings = []string{ `^(?:https?|ssh)://[^/]+/(?P.*)/(?P.*?)(?:\.git)?$`, `^.*?@.*:(?P.*)/(?P.*?)(?:\.git)?$`, } var defaultRepoURLTemplate = "https://{{.webDomain}}/{{.owner}}/{{.repo}}" // we've got less type safety using go templates but this lends itself better to // users adding custom service definitions in their config var githubServiceDef = ServiceDefinition{ provider: "github", pullRequestURLIntoDefaultBranch: "/compare/{{.From}}?expand=1", pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}?expand=1", commitURL: "/commit/{{.CommitHash}}", regexStrings: defaultUrlRegexStrings, repoURLTemplate: defaultRepoURLTemplate, } var bitbucketServiceDef = ServiceDefinition{ provider: "bitbucket", pullRequestURLIntoDefaultBranch: "/pull-requests/new?source={{.From}}&t=1", pullRequestURLIntoTargetBranch: "/pull-requests/new?source={{.From}}&dest={{.To}}&t=1", commitURL: "/commits/{{.CommitHash}}", regexStrings: []string{ `^(?:https?|ssh)://.*/(?P.*)/(?P.*?)(?:\.git)?$`, `^.*@.*:(?P.*)/(?P.*?)(?:\.git)?$`, }, repoURLTemplate: defaultRepoURLTemplate, } var gitLabServiceDef = ServiceDefinition{ provider: "gitlab", pullRequestURLIntoDefaultBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}", pullRequestURLIntoTargetBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}&merge_request%5Btarget_branch%5D={{.To}}", commitURL: "/-/commit/{{.CommitHash}}", regexStrings: defaultUrlRegexStrings, repoURLTemplate: defaultRepoURLTemplate, } var azdoServiceDef = ServiceDefinition{ provider: "azuredevops", pullRequestURLIntoDefaultBranch: "/pullrequestcreate?sourceRef={{.From}}", pullRequestURLIntoTargetBranch: "/pullrequestcreate?sourceRef={{.From}}&targetRef={{.To}}", commitURL: "/commit/{{.CommitHash}}", regexStrings: []string{ `^git@ssh.dev.azure.com.*/(?P.*)/(?P.*)/(?P.*?)(?:\.git)?$`, `^https://.*@dev.azure.com/(?P.*?)/(?P.*?)/_git/(?P.*?)(?:\.git)?$`, `^https://.*/(?P.*?)/(?P.*?)/_git/(?P.*?)(?:\.git)?$`, }, repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}", } var bitbucketServerServiceDef = ServiceDefinition{ provider: "bitbucketServer", pullRequestURLIntoDefaultBranch: "/pull-requests?create&sourceBranch={{.From}}", pullRequestURLIntoTargetBranch: "/pull-requests?create&targetBranch={{.To}}&sourceBranch={{.From}}", commitURL: "/commits/{{.CommitHash}}", regexStrings: []string{ `^ssh://git@.*/(?P.*)/(?P.*?)(?:\.git)?$`, `^https://.*/scm/(?P.*)/(?P.*?)(?:\.git)?$`, }, repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}", } var giteaServiceDef = ServiceDefinition{ provider: "gitea", pullRequestURLIntoDefaultBranch: "/compare/{{.From}}", pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}", commitURL: "/commit/{{.CommitHash}}", regexStrings: defaultUrlRegexStrings, repoURLTemplate: defaultRepoURLTemplate, } var serviceDefinitions = []ServiceDefinition{ githubServiceDef, bitbucketServiceDef, gitLabServiceDef, azdoServiceDef, bitbucketServerServiceDef, giteaServiceDef, } var defaultServiceDomains = []ServiceDomain{ { serviceDefinition: githubServiceDef, gitDomain: "github.com", webDomain: "github.com", }, { serviceDefinition: bitbucketServiceDef, gitDomain: "bitbucket.org", webDomain: "bitbucket.org", }, { serviceDefinition: gitLabServiceDef, gitDomain: "gitlab.com", webDomain: "gitlab.com", }, { serviceDefinition: azdoServiceDef, gitDomain: "dev.azure.com", webDomain: "dev.azure.com", }, { serviceDefinition: giteaServiceDef, gitDomain: "try.gitea.io", webDomain: "try.gitea.io", }, } lazygit-0.50.0+ds1/pkg/commands/hosting_service/hosting_service.go000066400000000000000000000137001500612110400252300ustar00rootroot00000000000000package hosting_service import ( "net/url" "regexp" "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sirupsen/logrus" "golang.org/x/exp/slices" ) // This package is for handling logic specific to a git hosting service like github, gitlab, bitbucket, gitea, etc. // Different git hosting services have different URL formats for when you want to open a PR or view a commit, // and this package's responsibility is to determine which service you're using based on the remote URL, // and then which URL you need for whatever use case you have. type HostingServiceMgr struct { log logrus.FieldLogger tr *i18n.TranslationSet remoteURL string // e.g. https://github.com/jesseduffield/lazygit // see https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-pull-request-urls configServiceDomains map[string]string } // NewHostingServiceMgr creates new instance of PullRequest func NewHostingServiceMgr(log logrus.FieldLogger, tr *i18n.TranslationSet, remoteURL string, configServiceDomains map[string]string) *HostingServiceMgr { return &HostingServiceMgr{ log: log, tr: tr, remoteURL: remoteURL, configServiceDomains: configServiceDomains, } } func (self *HostingServiceMgr) GetPullRequestURL(from string, to string) (string, error) { gitService, err := self.getService() if err != nil { return "", err } if to == "" { return gitService.getPullRequestURLIntoDefaultBranch(url.QueryEscape(from)), nil } else { return gitService.getPullRequestURLIntoTargetBranch(url.QueryEscape(from), url.QueryEscape(to)), nil } } func (self *HostingServiceMgr) GetCommitURL(commitHash string) (string, error) { gitService, err := self.getService() if err != nil { return "", err } pullRequestURL := gitService.getCommitURL(commitHash) return pullRequestURL, nil } func (self *HostingServiceMgr) getService() (*Service, error) { serviceDomain, err := self.getServiceDomain(self.remoteURL) if err != nil { return nil, err } repoURL, err := serviceDomain.serviceDefinition.getRepoURLFromRemoteURL(self.remoteURL, serviceDomain.webDomain) if err != nil { return nil, err } return &Service{ repoURL: repoURL, ServiceDefinition: serviceDomain.serviceDefinition, }, nil } func (self *HostingServiceMgr) getServiceDomain(repoURL string) (*ServiceDomain, error) { candidateServiceDomains := self.getCandidateServiceDomains() for _, serviceDomain := range candidateServiceDomains { if strings.Contains(repoURL, serviceDomain.gitDomain) { return &serviceDomain, nil } } return nil, errors.New(self.tr.UnsupportedGitService) } func (self *HostingServiceMgr) getCandidateServiceDomains() []ServiceDomain { serviceDefinitionByProvider := map[string]ServiceDefinition{} for _, serviceDefinition := range serviceDefinitions { serviceDefinitionByProvider[serviceDefinition.provider] = serviceDefinition } serviceDomains := slices.Clone(defaultServiceDomains) for gitDomain, typeAndDomain := range self.configServiceDomains { provider, webDomain, success := strings.Cut(typeAndDomain, ":") // we allow for one ':' for specifying the TCP port if !success || strings.Count(webDomain, ":") > 1 { self.log.Errorf("Unexpected format for git service: '%s'. Expected something like 'github.com:github.com'", typeAndDomain) continue } serviceDefinition, ok := serviceDefinitionByProvider[provider] if !ok { providerNames := lo.Map(serviceDefinitions, func(serviceDefinition ServiceDefinition, _ int) string { return serviceDefinition.provider }) self.log.Errorf("Unknown git service type: '%s'. Expected one of %s", provider, strings.Join(providerNames, ", ")) continue } serviceDomains = append(serviceDomains, ServiceDomain{ gitDomain: gitDomain, webDomain: webDomain, serviceDefinition: serviceDefinition, }) } return serviceDomains } // a service domains pairs a service definition with the actual domain it's being served from. // Sometimes the git service is hosted in a custom domains so although it'll use say // the github service definition, it'll actually be served from e.g. my-custom-github.com type ServiceDomain struct { gitDomain string // the one that appears in the git remote url webDomain string // the one that appears in the web url serviceDefinition ServiceDefinition } type ServiceDefinition struct { provider string pullRequestURLIntoDefaultBranch string pullRequestURLIntoTargetBranch string commitURL string regexStrings []string // can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex repoURLTemplate string } func (self ServiceDefinition) getRepoURLFromRemoteURL(url string, webDomain string) (string, error) { for _, regexStr := range self.regexStrings { re := regexp.MustCompile(regexStr) input := utils.FindNamedMatches(re, url) if input != nil { input["webDomain"] = webDomain return utils.ResolvePlaceholderString(self.repoURLTemplate, input), nil } } return "", errors.New("Failed to parse repo information from url") } type Service struct { repoURL string ServiceDefinition } func (self *Service) getPullRequestURLIntoDefaultBranch(from string) string { return self.resolveUrl(self.pullRequestURLIntoDefaultBranch, map[string]string{"From": from}) } func (self *Service) getPullRequestURLIntoTargetBranch(from string, to string) string { return self.resolveUrl(self.pullRequestURLIntoTargetBranch, map[string]string{"From": from, "To": to}) } func (self *Service) getCommitURL(commitHash string) string { return self.resolveUrl(self.commitURL, map[string]string{"CommitHash": commitHash}) } func (self *Service) resolveUrl(templateString string, args map[string]string) string { return self.repoURL + utils.ResolvePlaceholderString(templateString, args) } lazygit-0.50.0+ds1/pkg/commands/hosting_service/hosting_service_test.go000066400000000000000000000440221500612110400262700ustar00rootroot00000000000000package hosting_service import ( "testing" "github.com/jesseduffield/lazygit/pkg/fakes" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/stretchr/testify/assert" ) func TestGetPullRequestURL(t *testing.T) { type scenario struct { testName string from string to string remoteUrl string configServiceDomains map[string]string test func(url string, err error) expectedLoggedErrors []string } scenarios := []scenario{ { testName: "Opens a link to new pull request on bitbucket", from: "feature/profile-page", remoteUrl: "git@bitbucket.org:johndoe/social_network.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url) }, }, { testName: "Opens a link to new pull request on bitbucket with http remote url", from: "feature/events", remoteUrl: "https://my_username@bitbucket.org/johndoe/social_network.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fevents&t=1", url) }, }, { testName: "Opens a link to new pull request on github", from: "feature/sum-operation", remoteUrl: "git@github.com:peter/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://github.com/peter/calculator/compare/feature%2Fsum-operation?expand=1", url) }, }, { testName: "Opens a link to new pull request on github with https remote url", from: "feature/sum-operation", remoteUrl: "https://github.com/peter/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://github.com/peter/calculator/compare/feature%2Fsum-operation?expand=1", url) }, }, { testName: "Opens a link to new pull request on bitbucket with specific target branch", from: "feature/profile-page/avatar", to: "feature/profile-page", remoteUrl: "git@bitbucket.org:johndoe/social_network.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page%2Favatar&dest=feature%2Fprofile-page&t=1", url) }, }, { testName: "Opens a link to new pull request on bitbucket with http remote url with specified target branch", from: "feature/remote-events", to: "feature/events", remoteUrl: "https://my_username@bitbucket.org/johndoe/social_network.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fremote-events&dest=feature%2Fevents&t=1", url) }, }, { testName: "Opens a link to new pull request on github with specific target branch", from: "feature/sum-operation", to: "feature/operations", remoteUrl: "git@github.com:peter/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://github.com/peter/calculator/compare/feature%2Foperations...feature%2Fsum-operation?expand=1", url) }, }, { testName: "Opens a link to new pull request on github with specific target branch (different git username)", from: "feature/sum-operation", to: "feature/operations", remoteUrl: "ssh://org-12345@github.com:peter/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://github.com/peter/calculator/compare/feature%2Foperations...feature%2Fsum-operation?expand=1", url) }, }, { testName: "Opens a link to new pull request on github with https remote url with specific target branch", from: "feature/sum-operation", to: "feature/operations", remoteUrl: "https://github.com/peter/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://github.com/peter/calculator/compare/feature%2Foperations...feature%2Fsum-operation?expand=1", url) }, }, { testName: "Opens a link to new pull request on gitlab", from: "feature/ui", remoteUrl: "git@gitlab.com:peter/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://gitlab.com/peter/calculator/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fui", url) }, }, { testName: "Opens a link to new pull request on gitlab in nested groups", from: "feature/ui", remoteUrl: "git@gitlab.com:peter/public/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://gitlab.com/peter/public/calculator/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fui", url) }, }, { testName: "Opens a link to new pull request on gitlab with https remote url in nested groups", from: "feature/ui", remoteUrl: "https://gitlab.com/peter/public/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://gitlab.com/peter/public/calculator/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fui", url) }, }, { testName: "Opens a link to new pull request on gitlab with specific target branch", from: "feature/commit-ui", to: "epic/ui", remoteUrl: "git@gitlab.com:peter/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://gitlab.com/peter/calculator/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fcommit-ui&merge_request%5Btarget_branch%5D=epic%2Fui", url) }, }, { testName: "Opens a link to new pull request on gitlab with specific target branch in nested groups", from: "feature/commit-ui", to: "epic/ui", remoteUrl: "git@gitlab.com:peter/public/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://gitlab.com/peter/public/calculator/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fcommit-ui&merge_request%5Btarget_branch%5D=epic%2Fui", url) }, }, { testName: "Opens a link to new pull request on gitlab with https remote url with specific target branch in nested groups", from: "feature/commit-ui", to: "epic/ui", remoteUrl: "https://gitlab.com/peter/public/calculator.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://gitlab.com/peter/public/calculator/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fcommit-ui&merge_request%5Btarget_branch%5D=epic%2Fui", url) }, }, { testName: "Opens a link to new pull request on bitbucket with a custom SSH username", from: "feature/profile-page", remoteUrl: "john@bitbucket.org:johndoe/social_network.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url) }, }, { testName: "Opens a link to new pull request on Azure DevOps (SSH)", from: "feature/new", remoteUrl: "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Azure DevOps (SSH) with specific target", from: "feature/new", to: "dev", remoteUrl: "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew&targetRef=dev", url) }, }, { testName: "Opens a link to new pull request on Azure DevOps (HTTP)", from: "feature/new", remoteUrl: "https://myorg@dev.azure.com/myorg/myproject/_git/myrepo", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Azure DevOps (HTTP) with specific target", from: "feature/new", to: "dev", remoteUrl: "https://myorg@dev.azure.com/myorg/myproject/_git/myrepo", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew&targetRef=dev", url) }, }, { testName: "Opens a link to new pull request on Azure DevOps Server (HTTP)", from: "feature/new", remoteUrl: "https://mycompany.azuredevops.com/collection/myproject/_git/myrepo", configServiceDomains: map[string]string{ // valid configuration for a azure devops server URL "mycompany.azuredevops.com": "azuredevops:mycompany.azuredevops.com", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.azuredevops.com/collection/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Bitbucket Server (SSH)", from: "feature/new", remoteUrl: "ssh://git@mycompany.bitbucket.com/myproject/myrepo.git", configServiceDomains: map[string]string{ // valid configuration for a bitbucket server URL "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.bitbucket.com/projects/myproject/repos/myrepo/pull-requests?create&sourceBranch=feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Bitbucket Server (SSH) with specific target", from: "feature/new", to: "dev", remoteUrl: "ssh://git@mycompany.bitbucket.com/myproject/myrepo.git", configServiceDomains: map[string]string{ // valid configuration for a bitbucket server URL "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.bitbucket.com/projects/myproject/repos/myrepo/pull-requests?create&targetBranch=dev&sourceBranch=feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Bitbucket Server (HTTP)", from: "feature/new", remoteUrl: "https://mycompany.bitbucket.com/scm/myproject/myrepo.git", configServiceDomains: map[string]string{ // valid configuration for a bitbucket server URL "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.bitbucket.com/projects/myproject/repos/myrepo/pull-requests?create&sourceBranch=feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Bitbucket Server (HTTP) with specific target", from: "feature/new", to: "dev", remoteUrl: "https://mycompany.bitbucket.com/scm/myproject/myrepo.git", configServiceDomains: map[string]string{ // valid configuration for a bitbucket server URL "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.bitbucket.com/projects/myproject/repos/myrepo/pull-requests?create&targetBranch=dev&sourceBranch=feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Gitea Server (SSH)", from: "feature/new", remoteUrl: "ssh://git@mycompany.gitea.io/myproject/myrepo.git", configServiceDomains: map[string]string{ // valid configuration for a gitea server URL "mycompany.gitea.io": "gitea:mycompany.gitea.io", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.gitea.io/myproject/myrepo/compare/feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Gitea Server (SSH) with specific target", from: "feature/new", to: "dev", remoteUrl: "ssh://git@mycompany.gitea.io/myproject/myrepo.git", configServiceDomains: map[string]string{ // valid configuration for a gitea server URL "mycompany.gitea.io": "gitea:mycompany.gitea.io", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.gitea.io/myproject/myrepo/compare/dev...feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Gitea Server (HTTP)", from: "feature/new", remoteUrl: "https://mycompany.gitea.io/myproject/myrepo.git", configServiceDomains: map[string]string{ // valid configuration for a gitea server URL "mycompany.gitea.io": "gitea:mycompany.gitea.io", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.gitea.io/myproject/myrepo/compare/feature%2Fnew", url) }, }, { testName: "Opens a link to new pull request on Gitea Server (HTTP) with specific target", from: "feature/new", to: "dev", remoteUrl: "https://mycompany.gitea.io/myproject/myrepo.git", configServiceDomains: map[string]string{ // valid configuration for a gitea server URL "mycompany.gitea.io": "gitea:mycompany.gitea.io", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://mycompany.gitea.io/myproject/myrepo/compare/dev...feature%2Fnew", url) }, }, { testName: "Throws an error if git service is unsupported", from: "feature/divide-operation", remoteUrl: "git@something.com:peter/calculator.git", test: func(url string, err error) { assert.EqualError(t, err, "Unsupported git service") }, }, { testName: "Does not log error when config service domains are valid", from: "feature/profile-page", remoteUrl: "git@bitbucket.org:johndoe/social_network.git", configServiceDomains: map[string]string{ // valid configuration for a custom service URL "git.work.com": "gitlab:code.work.com", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url) }, expectedLoggedErrors: nil, }, { testName: "Does not log error when config service webDomain contains a port", from: "feature/profile-page", remoteUrl: "git@my.domain.test:johndoe/social_network.git", configServiceDomains: map[string]string{ "my.domain.test": "gitlab:my.domain.test:1111", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://my.domain.test:1111/johndoe/social_network/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fprofile-page", url) }, }, { testName: "Logs error when webDomain contains more than one colon", from: "feature/profile-page", remoteUrl: "git@my.domain.test:johndoe/social_network.git", configServiceDomains: map[string]string{ "my.domain.test": "gitlab:my.domain.test:1111:2222", }, test: func(url string, err error) { assert.Error(t, err) }, expectedLoggedErrors: []string{"Unexpected format for git service: 'gitlab:my.domain.test:1111:2222'. Expected something like 'github.com:github.com'"}, }, { testName: "Logs error when config service domain is malformed", from: "feature/profile-page", remoteUrl: "git@bitbucket.org:johndoe/social_network.git", configServiceDomains: map[string]string{ "noservice.work.com": "noservice.work.com", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url) }, expectedLoggedErrors: []string{"Unexpected format for git service: 'noservice.work.com'. Expected something like 'github.com:github.com'"}, }, { testName: "Logs error when config service domain uses unknown provider", from: "feature/profile-page", remoteUrl: "git@bitbucket.org:johndoe/social_network.git", configServiceDomains: map[string]string{ "invalid.work.com": "noservice:invalid.work.com", }, test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url) }, expectedLoggedErrors: []string{"Unknown git service type: 'noservice'. Expected one of github, bitbucket, gitlab, azuredevops, bitbucketServer, gitea"}, }, { testName: "Escapes reserved URL characters in from branch name", from: "feature/someIssue#123", to: "master", remoteUrl: "git@gitlab.com:me/public/repo-with-issues.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://gitlab.com/me/public/repo-with-issues/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2FsomeIssue%23123&merge_request%5Btarget_branch%5D=master", url) }, }, { testName: "Escapes reserved URL characters in to branch name", from: "yolo", to: "archive/never-ending-feature#666", remoteUrl: "git@gitlab.com:me/public/repo-with-issues.git", test: func(url string, err error) { assert.NoError(t, err) assert.Equal(t, "https://gitlab.com/me/public/repo-with-issues/-/merge_requests/new?merge_request%5Bsource_branch%5D=yolo&merge_request%5Btarget_branch%5D=archive%2Fnever-ending-feature%23666", url) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { tr := i18n.EnglishTranslationSet() log := &fakes.FakeFieldLogger{} hostingServiceMgr := NewHostingServiceMgr(log, tr, s.remoteUrl, s.configServiceDomains) s.test(hostingServiceMgr.GetPullRequestURL(s.from, s.to)) log.AssertErrors(t, s.expectedLoggedErrors) }) } } lazygit-0.50.0+ds1/pkg/commands/models/000077500000000000000000000000001500612110400175755ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/models/author.go000066400000000000000000000003051500612110400214240ustar00rootroot00000000000000package models import "fmt" // A commit author type Author struct { Name string Email string } func (self *Author) Combined() string { return fmt.Sprintf("%s <%s>", self.Name, self.Email) } lazygit-0.50.0+ds1/pkg/commands/models/branch.go000066400000000000000000000071731500612110400213710ustar00rootroot00000000000000package models import ( "fmt" "sync/atomic" ) // Branch : A git branch // duplicating this for now type Branch struct { Name string // the displayname is something like '(HEAD detached at 123asdf)', whereas in that case the name would be '123asdf' DisplayName string // indicator of when the branch was last checked out e.g. '2d', '3m' Recency string // how many commits ahead we are from the remote branch (how many commits we can push, assuming we push to our tracked remote branch) AheadForPull string // how many commits behind we are from the remote branch (how many commits we can pull) BehindForPull string // how many commits ahead we are from the branch we're pushing to (which might not be the same as our upstream branch in a triangular workflow) AheadForPush string // how many commits behind we are from the branch we're pushing to (which might not be the same as our upstream branch in a triangular workflow) BehindForPush string // whether the remote branch is 'gone' i.e. we're tracking a remote branch that has been deleted UpstreamGone bool // whether this is the current branch. Exactly one branch should have this be true Head bool DetachedHead bool // if we have a named remote locally this will be the name of that remote e.g. // 'origin' or 'tiwood'. If we don't have the remote locally it'll look like // 'git@github.com:tiwood/lazygit.git' UpstreamRemote string UpstreamBranch string // subject line in commit message Subject string // commit hash CommitHash string // How far we have fallen behind our base branch. 0 means either not // determined yet, or up to date with base branch. (We don't need to // distinguish the two, as we don't draw anything in both cases.) BehindBaseBranch atomic.Int32 } func (b *Branch) FullRefName() string { if b.DetachedHead { return b.Name } return "refs/heads/" + b.Name } func (b *Branch) RefName() string { return b.Name } func (b *Branch) ShortRefName() string { return b.RefName() } func (b *Branch) ParentRefName() string { return b.RefName() + "^" } func (b *Branch) FullUpstreamRefName() string { if b.UpstreamRemote == "" || b.UpstreamBranch == "" { return "" } return fmt.Sprintf("refs/remotes/%s/%s", b.UpstreamRemote, b.UpstreamBranch) } func (b *Branch) ShortUpstreamRefName() string { if b.UpstreamRemote == "" || b.UpstreamBranch == "" { return "" } return fmt.Sprintf("%s/%s", b.UpstreamRemote, b.UpstreamBranch) } func (b *Branch) ID() string { return b.RefName() } func (b *Branch) URN() string { return "branch-" + b.ID() } func (b *Branch) Description() string { return b.RefName() } func (b *Branch) IsTrackingRemote() bool { return b.UpstreamRemote != "" } // we know that the remote branch is not stored locally based on our pushable/pullable // count being question marks. func (b *Branch) RemoteBranchStoredLocally() bool { return b.IsTrackingRemote() && b.AheadForPull != "?" && b.BehindForPull != "?" } func (b *Branch) RemoteBranchNotStoredLocally() bool { return b.IsTrackingRemote() && b.AheadForPull == "?" && b.BehindForPull == "?" } func (b *Branch) MatchesUpstream() bool { return b.RemoteBranchStoredLocally() && b.AheadForPull == "0" && b.BehindForPull == "0" } func (b *Branch) IsAheadForPull() bool { return b.RemoteBranchStoredLocally() && b.AheadForPull != "0" } func (b *Branch) IsBehindForPull() bool { return b.RemoteBranchStoredLocally() && b.BehindForPull != "0" } func (b *Branch) IsBehindForPush() bool { return b.RemoteBranchStoredLocally() && b.BehindForPush != "0" } // for when we're in a detached head state func (b *Branch) IsRealBranch() bool { return b.AheadForPull != "" && b.BehindForPull != "" } lazygit-0.50.0+ds1/pkg/commands/models/commit.go000066400000000000000000000070231500612110400214160ustar00rootroot00000000000000package models import ( "fmt" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) // Special commit hash for empty tree object const EmptyTreeCommitHash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" type CommitStatus uint8 const ( StatusNone CommitStatus = iota StatusUnpushed StatusPushed StatusMerged StatusRebasing StatusCherryPickingOrReverting StatusConflicted StatusReflog ) const ( // Conveniently for us, the todo package starts the enum at 1, and given // that it doesn't have a "none" value, we're setting ours to 0 ActionNone todo.TodoCommand = 0 ) type Divergence uint8 // For a divergence log (left/right comparison of two refs) this is set to // either DivergenceLeft or DivergenceRight for each commit; for normal // commit views it is always DivergenceNone. const ( DivergenceNone Divergence = iota DivergenceLeft DivergenceRight ) // Commit : A git commit type Commit struct { hash *string Name string Tags []string ExtraInfo string // something like 'HEAD -> master, tag: v0.15.2' AuthorName string // something like 'Jesse Duffield' AuthorEmail string // something like 'jessedduffield@gmail.com' UnixTimestamp int64 // Hashes of parent commits (will be multiple if it's a merge commit) parents []*string Status CommitStatus Action todo.TodoCommand Divergence Divergence // set to DivergenceNone unless we are showing the divergence view } type NewCommitOpts struct { Hash string Name string Status CommitStatus Action todo.TodoCommand Tags []string ExtraInfo string AuthorName string AuthorEmail string UnixTimestamp int64 Divergence Divergence Parents []string } func NewCommit(hashPool *utils.StringPool, opts NewCommitOpts) *Commit { return &Commit{ hash: hashPool.Add(opts.Hash), Name: opts.Name, Status: opts.Status, Action: opts.Action, Tags: opts.Tags, ExtraInfo: opts.ExtraInfo, AuthorName: opts.AuthorName, AuthorEmail: opts.AuthorEmail, UnixTimestamp: opts.UnixTimestamp, Divergence: opts.Divergence, parents: lo.Map(opts.Parents, func(s string, _ int) *string { return hashPool.Add(s) }), } } func (c *Commit) Hash() string { return *c.hash } func (c *Commit) HashPtr() *string { return c.hash } func (c *Commit) ShortHash() string { return utils.ShortHash(c.Hash()) } func (c *Commit) FullRefName() string { return c.Hash() } func (c *Commit) RefName() string { return c.Hash() } func (c *Commit) ShortRefName() string { return c.Hash()[:7] } func (c *Commit) ParentRefName() string { if c.IsFirstCommit() { return EmptyTreeCommitHash } return c.RefName() + "^" } func (c *Commit) Parents() []string { return lo.Map(c.parents, func(s *string, _ int) string { return *s }) } func (c *Commit) ParentPtrs() []*string { return c.parents } func (c *Commit) IsFirstCommit() bool { return len(c.parents) == 0 } func (c *Commit) ID() string { return c.RefName() } func (c *Commit) Description() string { return fmt.Sprintf("%s %s", c.Hash()[:7], c.Name) } func (c *Commit) IsMerge() bool { return len(c.parents) > 1 } // returns true if this commit is not actually in the git log but instead // is from a TODO file for an interactive rebase. func (c *Commit) IsTODO() bool { return c.Action != ActionNone } func IsHeadCommit(commits []*Commit, index int) bool { return !commits[index].IsTODO() && (index == 0 || commits[index-1].IsTODO()) } lazygit-0.50.0+ds1/pkg/commands/models/commit_file.go000066400000000000000000000010141500612110400224070ustar00rootroot00000000000000package models // CommitFile : A git commit file type CommitFile struct { Path string ChangeStatus string // e.g. 'A' for added or 'M' for modified. This is based on the result from git diff --name-status } func (f *CommitFile) ID() string { return f.Path } func (f *CommitFile) Description() string { return f.Path } func (f *CommitFile) Added() bool { return f.ChangeStatus == "A" } func (f *CommitFile) Deleted() bool { return f.ChangeStatus == "D" } func (f *CommitFile) GetPath() string { return f.Path } lazygit-0.50.0+ds1/pkg/commands/models/file.go000066400000000000000000000105241500612110400210450ustar00rootroot00000000000000package models import ( "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // File : A file from git status // duplicating this for now type File struct { Path string PreviousPath string HasStagedChanges bool HasUnstagedChanges bool Tracked bool Added bool Deleted bool HasMergeConflicts bool HasInlineMergeConflicts bool DisplayString string ShortStatus string // e.g. 'AD', ' A', 'M ', '??' LinesDeleted int LinesAdded int // If true, this must be a worktree folder IsWorktree bool } // sometimes we need to deal with either a node (which contains a file) or an actual file type IFile interface { GetHasUnstagedChanges() bool GetHasStagedChanges() bool GetIsTracked() bool GetPath() string GetPreviousPath() string GetIsFile() bool } func (f *File) IsRename() bool { return f.PreviousPath != "" } // Names returns an array containing just the filename, or in the case of a rename, the after filename and the before filename func (f *File) Names() []string { result := []string{f.Path} if f.PreviousPath != "" { result = append(result, f.PreviousPath) } return result } // returns true if the file names are the same or if a file rename includes the filename of the other func (f *File) Matches(f2 *File) bool { return utils.StringArraysOverlap(f.Names(), f2.Names()) } func (f *File) ID() string { return f.Path } func (f *File) Description() string { return f.Path } func (f *File) IsSubmodule(configs []*SubmoduleConfig) bool { return f.SubmoduleConfig(configs) != nil } func (f *File) SubmoduleConfig(configs []*SubmoduleConfig) *SubmoduleConfig { for _, config := range configs { if f.Path == config.Path { return config } } return nil } func (f *File) GetHasUnstagedChanges() bool { return f.HasUnstagedChanges } func (f *File) GetHasStagedChanges() bool { return f.HasStagedChanges } func (f *File) GetIsTracked() bool { return f.Tracked } func (f *File) GetPath() string { // TODO: remove concept of name; just use path return f.Path } func (f *File) GetPreviousPath() string { return f.PreviousPath } func (f *File) GetIsFile() bool { return true } func (f *File) GetMergeStateDescription(tr *i18n.TranslationSet) string { m := map[string]string{ "DD": tr.MergeConflictDescription_DD, "AU": tr.MergeConflictDescription_AU, "UA": tr.MergeConflictDescription_UA, "DU": tr.MergeConflictDescription_DU, "UD": tr.MergeConflictDescription_UD, } if description, ok := m[f.ShortStatus]; ok { return description } panic("should only be called if there's a merge conflict") } type StatusFields struct { HasStagedChanges bool HasUnstagedChanges bool Tracked bool Deleted bool Added bool HasMergeConflicts bool HasInlineMergeConflicts bool ShortStatus string } func SetStatusFields(file *File, shortStatus string) { derived := deriveStatusFields(shortStatus) file.HasStagedChanges = derived.HasStagedChanges file.HasUnstagedChanges = derived.HasUnstagedChanges file.Tracked = derived.Tracked file.Deleted = derived.Deleted file.Added = derived.Added file.HasMergeConflicts = derived.HasMergeConflicts file.HasInlineMergeConflicts = derived.HasInlineMergeConflicts file.ShortStatus = derived.ShortStatus } // shortStatus is something like '??' or 'A ' func deriveStatusFields(shortStatus string) StatusFields { stagedChange := shortStatus[0:1] unstagedChange := shortStatus[1:2] tracked := !lo.Contains([]string{"??", "A ", "AM"}, shortStatus) hasStagedChanges := !lo.Contains([]string{" ", "U", "?"}, stagedChange) hasInlineMergeConflicts := lo.Contains([]string{"UU", "AA"}, shortStatus) hasMergeConflicts := hasInlineMergeConflicts || lo.Contains([]string{"DD", "AU", "UA", "UD", "DU"}, shortStatus) return StatusFields{ HasStagedChanges: hasStagedChanges, HasUnstagedChanges: unstagedChange != " ", Tracked: tracked, Deleted: unstagedChange == "D" || stagedChange == "D", Added: unstagedChange == "A" || !tracked, HasMergeConflicts: hasMergeConflicts, HasInlineMergeConflicts: hasInlineMergeConflicts, ShortStatus: shortStatus, } } lazygit-0.50.0+ds1/pkg/commands/models/remote.go000066400000000000000000000005471500612110400214250ustar00rootroot00000000000000package models // Remote : A git remote type Remote struct { Name string Urls []string Branches []*RemoteBranch } func (r *Remote) RefName() string { return r.Name } func (r *Remote) ID() string { return r.RefName() } func (r *Remote) URN() string { return "remote-" + r.ID() } func (r *Remote) Description() string { return r.RefName() } lazygit-0.50.0+ds1/pkg/commands/models/remote_branch.go000066400000000000000000000011721500612110400227350ustar00rootroot00000000000000package models // Remote Branch : A git remote branch type RemoteBranch struct { Name string RemoteName string } func (r *RemoteBranch) FullName() string { return r.RemoteName + "/" + r.Name } func (r *RemoteBranch) FullRefName() string { return "refs/remotes/" + r.FullName() } func (r *RemoteBranch) RefName() string { return r.FullName() } func (r *RemoteBranch) ShortRefName() string { return r.RefName() } func (r *RemoteBranch) ParentRefName() string { return r.RefName() + "^" } func (r *RemoteBranch) ID() string { return r.RefName() } func (r *RemoteBranch) Description() string { return r.RefName() } lazygit-0.50.0+ds1/pkg/commands/models/stash_entry.go000066400000000000000000000010751500612110400224720ustar00rootroot00000000000000package models import "fmt" // StashEntry : A git stash entry type StashEntry struct { Index int Recency string Name string } func (s *StashEntry) FullRefName() string { return s.RefName() } func (s *StashEntry) RefName() string { return fmt.Sprintf("stash@{%d}", s.Index) } func (s *StashEntry) ShortRefName() string { return s.RefName() } func (s *StashEntry) ParentRefName() string { return s.RefName() + "^" } func (s *StashEntry) ID() string { return s.RefName() } func (s *StashEntry) Description() string { return s.RefName() + ": " + s.Name } lazygit-0.50.0+ds1/pkg/commands/models/submodule_config.go000066400000000000000000000016071500612110400234540ustar00rootroot00000000000000package models import "path/filepath" type SubmoduleConfig struct { Name string Path string Url string ParentModule *SubmoduleConfig // nil if top-level } func (r *SubmoduleConfig) FullName() string { if r.ParentModule != nil { return r.ParentModule.FullName() + "/" + r.Name } return r.Name } func (r *SubmoduleConfig) FullPath() string { if r.ParentModule != nil { return r.ParentModule.FullPath() + "/" + r.Path } return r.Path } func (r *SubmoduleConfig) RefName() string { return r.FullName() } func (r *SubmoduleConfig) ID() string { return r.RefName() } func (r *SubmoduleConfig) Description() string { return r.RefName() } func (r *SubmoduleConfig) GitDirPath(repoGitDirPath string) string { parentPath := repoGitDirPath if r.ParentModule != nil { parentPath = r.ParentModule.GitDirPath(repoGitDirPath) } return filepath.Join(parentPath, "modules", r.Name) } lazygit-0.50.0+ds1/pkg/commands/models/tag.go000066400000000000000000000011761500612110400207040ustar00rootroot00000000000000package models // Tag : A git tag type Tag struct { Name string // this is either the first line of the message of an annotated tag, or the // first line of a commit message for a lightweight tag Message string } func (t *Tag) FullRefName() string { return "refs/tags/" + t.RefName() } func (t *Tag) RefName() string { return t.Name } func (t *Tag) ShortRefName() string { return t.RefName() } func (t *Tag) ParentRefName() string { return t.RefName() + "^" } func (t *Tag) ID() string { return t.RefName() } func (t *Tag) URN() string { return "tag-" + t.ID() } func (t *Tag) Description() string { return t.Message } lazygit-0.50.0+ds1/pkg/commands/models/working_tree_state.go000066400000000000000000000077061500612110400240350ustar00rootroot00000000000000package models import "github.com/jesseduffield/lazygit/pkg/i18n" // The state of the working tree. Several of these can be true at once. // In particular, the concrete multi-state combinations that can occur in // practice are Rebasing+CherryPicking, and Rebasing+Reverting. Theoretically, I // guess Rebasing+Merging could also happen, but it probably won't in practice. type WorkingTreeState struct { Rebasing bool Merging bool CherryPicking bool Reverting bool } func (self WorkingTreeState) Any() bool { return self.Rebasing || self.Merging || self.CherryPicking || self.Reverting } func (self WorkingTreeState) None() bool { return !self.Any() } type EffectiveWorkingTreeState int const ( // this means we're neither rebasing nor merging, cherry-picking, or reverting WORKING_TREE_STATE_NONE EffectiveWorkingTreeState = iota WORKING_TREE_STATE_REBASING WORKING_TREE_STATE_MERGING WORKING_TREE_STATE_CHERRY_PICKING WORKING_TREE_STATE_REVERTING ) // Effective returns the "current" state; if several states are true at once, // this is the one that should be displayed in status views, and it's the one // that the user can continue or abort. // // As an example, if you are stopped in an interactive rebase, and then you // perform a cherry-pick, and the cherry-pick conflicts, then both // WorkingTreeState.Rebasing and WorkingTreeState.CherryPicking are true. // The effective state is cherry-picking, because that's the one you can // continue or abort. It is not possible to continue the rebase without first // aborting the cherry-pick. func (self WorkingTreeState) Effective() EffectiveWorkingTreeState { if self.Reverting { return WORKING_TREE_STATE_REVERTING } if self.CherryPicking { return WORKING_TREE_STATE_CHERRY_PICKING } if self.Merging { return WORKING_TREE_STATE_MERGING } if self.Rebasing { return WORKING_TREE_STATE_REBASING } return WORKING_TREE_STATE_NONE } func (self WorkingTreeState) Title(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.RebasingStatus, WORKING_TREE_STATE_MERGING: tr.MergingStatus, WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickingStatus, WORKING_TREE_STATE_REVERTING: tr.RevertingStatus, }[self.Effective()] } func (self WorkingTreeState) LowerCaseTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.LowercaseRebasingStatus, WORKING_TREE_STATE_MERGING: tr.LowercaseMergingStatus, WORKING_TREE_STATE_CHERRY_PICKING: tr.LowercaseCherryPickingStatus, WORKING_TREE_STATE_REVERTING: tr.LowercaseRevertingStatus, }[self.Effective()] } func (self WorkingTreeState) OptionsMenuTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.RebaseOptionsTitle, WORKING_TREE_STATE_MERGING: tr.MergeOptionsTitle, WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOptionsTitle, WORKING_TREE_STATE_REVERTING: tr.RevertOptionsTitle, }[self.Effective()] } func (self WorkingTreeState) OptionsMapTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.ViewRebaseOptions, WORKING_TREE_STATE_MERGING: tr.ViewMergeOptions, WORKING_TREE_STATE_CHERRY_PICKING: tr.ViewCherryPickOptions, WORKING_TREE_STATE_REVERTING: tr.ViewRevertOptions, }[self.Effective()] } func (self WorkingTreeState) CommandName() string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: "rebase", WORKING_TREE_STATE_MERGING: "merge", WORKING_TREE_STATE_CHERRY_PICKING: "cherry-pick", WORKING_TREE_STATE_REVERTING: "revert", }[self.Effective()] } func (self WorkingTreeState) CanShowTodos() bool { return self.Rebasing || self.CherryPicking || self.Reverting } func (self WorkingTreeState) CanSkip() bool { return self.Rebasing || self.CherryPicking || self.Reverting } lazygit-0.50.0+ds1/pkg/commands/models/worktree.go000066400000000000000000000022521500612110400217670ustar00rootroot00000000000000package models // A git worktree type Worktree struct { // if false, this is a linked worktree IsMain bool // if true, this is the worktree that is currently checked out IsCurrent bool // path to the directory of the worktree i.e. the directory that contains all the user's files Path string // if true, the path is not found IsPathMissing bool // path of the git directory for this worktree. The equivalent of the .git directory // in the main worktree. For linked worktrees this would be /.git/worktrees/ GitDir string // If the worktree has a branch checked out, this field will be set to the branch name. // A branch is considered 'checked out' if: // * the worktree is directly on the branch // * the worktree is mid-rebase on the branch // * the worktree is mid-bisect on the branch Branch string // based on the path, but uniquified. Not the same name that git uses in the worktrees/ folder (no good reason for this, // I just prefer my naming convention better) Name string } func (w *Worktree) RefName() string { return w.Name } func (w *Worktree) ID() string { return w.Path } func (w *Worktree) Description() string { return w.RefName() } lazygit-0.50.0+ds1/pkg/commands/oscommands/000077500000000000000000000000001500612110400204555ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/oscommands/cmd_obj.go000066400000000000000000000142731500612110400224100ustar00rootroot00000000000000package oscommands import ( "os/exec" "strings" "github.com/jesseduffield/gocui" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" ) // A command object is a general way to represent a command to be run on the // command line. type ICmdObj interface { GetCmd() *exec.Cmd // outputs string representation of command. Note that if the command was built // using NewFromArgs, the output won't be quite the same as what you would type // into a terminal e.g. 'sh -c git commit' as opposed to 'sh -c "git commit"' ToString() string // outputs args vector e.g. ["git", "commit", "-m", "my message"] Args() []string // Set a string to be used as stdin for the command. SetStdin(input string) ICmdObj AddEnvVars(...string) ICmdObj GetEnvVars() []string // sets the working directory SetWd(string) ICmdObj // runs the command and returns an error if any Run() error // runs the command and returns the output as a string, and an error if any RunWithOutput() (string, error) // runs the command and returns stdout and stderr as a string, and an error if any RunWithOutputs() (string, string, error) // runs the command and runs a callback function on each line of the output. If the callback returns true for the boolean value, we kill the process and return. RunAndProcessLines(onLine func(line string) (bool, error)) error // Be calling DontLog(), we're saying that once we call Run(), we don't want to // log the command in the UI (it'll still be logged in the log file). The general rule // is that if a command doesn't change the git state (e.g. read commands like `git diff`) // then we don't want to log it. If we are changing something (e.g. `git add .`) then // we do. The only exception is if we're running a command in the background periodically // like `git fetch`, which technically does mutate stuff but isn't something we need // to notify the user about. DontLog() ICmdObj // This returns false if DontLog() was called ShouldLog() bool // when you call this, then call Run(), we'll stream the output to the cmdWriter (i.e. the command log panel) StreamOutput() ICmdObj // returns true if StreamOutput() was called ShouldStreamOutput() bool // if you call this before ShouldStreamOutput we'll consider an error with no // stderr content as a non-error. Not yet supported for Run or RunWithOutput ( // but adding support is trivial) IgnoreEmptyError() ICmdObj // returns true if IgnoreEmptyError() was called ShouldIgnoreEmptyError() bool PromptOnCredentialRequest(task gocui.Task) ICmdObj FailOnCredentialRequest() ICmdObj WithMutex(mutex *deadlock.Mutex) ICmdObj Mutex() *deadlock.Mutex GetCredentialStrategy() CredentialStrategy GetTask() gocui.Task Clone() ICmdObj } type CmdObj struct { cmd *exec.Cmd runner ICmdObjRunner // see DontLog() dontLog bool // see StreamOutput() streamOutput bool // see IgnoreEmptyError() ignoreEmptyError bool // if set to true, it means we might be asked to enter a username/password by this command. credentialStrategy CredentialStrategy task gocui.Task // can be set so that we don't run certain commands simultaneously mutex *deadlock.Mutex } type CredentialStrategy int const ( // do not expect a credential request. If we end up getting one // we'll be in trouble because the command will hang indefinitely NONE CredentialStrategy = iota // expect a credential request and if we get one, prompt the user to enter their username/password PROMPT // in this case we will check for a credential request (i.e. the command pauses to ask for // username/password) and if we get one, we just submit a newline, forcing the // command to fail. We use this e.g. for a background `git fetch` to prevent it // from hanging indefinitely. FAIL ) var _ ICmdObj = &CmdObj{} func (self *CmdObj) GetCmd() *exec.Cmd { return self.cmd } func (self *CmdObj) ToString() string { // if a given arg contains a space, we need to wrap it in quotes quotedArgs := lo.Map(self.cmd.Args, func(arg string, _ int) string { if strings.Contains(arg, " ") { return `"` + arg + `"` } return arg }) return strings.Join(quotedArgs, " ") } func (self *CmdObj) Args() []string { return self.cmd.Args } func (self *CmdObj) SetStdin(input string) ICmdObj { self.cmd.Stdin = strings.NewReader(input) return self } func (self *CmdObj) AddEnvVars(vars ...string) ICmdObj { self.cmd.Env = append(self.cmd.Env, vars...) return self } func (self *CmdObj) GetEnvVars() []string { return self.cmd.Env } func (self *CmdObj) SetWd(wd string) ICmdObj { self.cmd.Dir = wd return self } func (self *CmdObj) DontLog() ICmdObj { self.dontLog = true return self } func (self *CmdObj) ShouldLog() bool { return !self.dontLog } func (self *CmdObj) StreamOutput() ICmdObj { self.streamOutput = true return self } func (self *CmdObj) ShouldStreamOutput() bool { return self.streamOutput } func (self *CmdObj) IgnoreEmptyError() ICmdObj { self.ignoreEmptyError = true return self } func (self *CmdObj) Mutex() *deadlock.Mutex { return self.mutex } func (self *CmdObj) WithMutex(mutex *deadlock.Mutex) ICmdObj { self.mutex = mutex return self } func (self *CmdObj) ShouldIgnoreEmptyError() bool { return self.ignoreEmptyError } func (self *CmdObj) Run() error { return self.runner.Run(self) } func (self *CmdObj) RunWithOutput() (string, error) { return self.runner.RunWithOutput(self) } func (self *CmdObj) RunWithOutputs() (string, string, error) { return self.runner.RunWithOutputs(self) } func (self *CmdObj) RunAndProcessLines(onLine func(line string) (bool, error)) error { return self.runner.RunAndProcessLines(self, onLine) } func (self *CmdObj) PromptOnCredentialRequest(task gocui.Task) ICmdObj { self.credentialStrategy = PROMPT self.task = task return self } func (self *CmdObj) FailOnCredentialRequest() ICmdObj { self.credentialStrategy = FAIL return self } func (self *CmdObj) GetCredentialStrategy() CredentialStrategy { return self.credentialStrategy } func (self *CmdObj) GetTask() gocui.Task { return self.task } func (self *CmdObj) Clone() ICmdObj { clone := &CmdObj{} *clone = *self clone.cmd = cloneCmd(self.cmd) return clone } func cloneCmd(cmd *exec.Cmd) *exec.Cmd { clone := &exec.Cmd{} *clone = *cmd return clone } lazygit-0.50.0+ds1/pkg/commands/oscommands/cmd_obj_builder.go000066400000000000000000000056301500612110400241130ustar00rootroot00000000000000package oscommands import ( "fmt" "os" "os/exec" "strings" "github.com/mgutz/str" ) type ICmdObjBuilder interface { // NewFromArgs takes a slice of strings like []string{"git", "commit"} and returns a new command object. New(args []string) ICmdObj // NewShell takes a string like `git commit` and returns an executable shell command for it e.g. `sh -c 'git commit'` // shellFunctionsFile is an optional file path that will be sourced before executing the command. Callers should pass // the value of UserConfig.OS.ShellFunctionsFile. NewShell(commandStr string, shellFunctionsFile string) ICmdObj // Quote wraps a string in quotes with any necessary escaping applied. The reason for bundling this up with the other methods in this interface is that we basically always need to make use of this when creating new command objects. Quote(str string) string } type CmdObjBuilder struct { runner ICmdObjRunner platform *Platform } // poor man's version of explicitly saying that struct X implements interface Y var _ ICmdObjBuilder = &CmdObjBuilder{} func (self *CmdObjBuilder) New(args []string) ICmdObj { cmdObj := self.NewWithEnviron(args, os.Environ()) return cmdObj } // A command with explicit environment from env func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) ICmdObj { cmd := exec.Command(args[0], args[1:]...) cmd.Env = env return &CmdObj{ cmd: cmd, runner: self.runner, } } func (self *CmdObjBuilder) NewShell(commandStr string, shellFunctionsFile string) ICmdObj { if len(shellFunctionsFile) > 0 { commandStr = fmt.Sprintf("%ssource %s\n%s", self.platform.PrefixForShellFunctionsFile, shellFunctionsFile, commandStr) } quotedCommand := self.quotedCommandString(commandStr) cmdArgs := str.ToArgv(fmt.Sprintf("%s %s %s", self.platform.Shell, self.platform.ShellArg, quotedCommand)) return self.New(cmdArgs) } func (self *CmdObjBuilder) quotedCommandString(commandStr string) string { // Windows does not seem to like quotes around the command if self.platform.OS == "windows" { return strings.NewReplacer( "^", "^^", "&", "^&", "|", "^|", "<", "^<", ">", "^>", "%", "^%", ).Replace(commandStr) } return self.Quote(commandStr) } func (self *CmdObjBuilder) CloneWithNewRunner(decorate func(ICmdObjRunner) ICmdObjRunner) *CmdObjBuilder { decoratedRunner := decorate(self.runner) return &CmdObjBuilder{ runner: decoratedRunner, platform: self.platform, } } const CHARS_REQUIRING_QUOTES = "\"\\$` " // If you update this method, be sure to update CHARS_REQUIRING_QUOTES func (self *CmdObjBuilder) Quote(message string) string { var quote string if self.platform.OS == "windows" { quote = `\"` message = strings.NewReplacer( `"`, `"'"'"`, `\"`, `\\"`, ).Replace(message) } else { quote = `"` message = strings.NewReplacer( `\`, `\\`, `"`, `\"`, `$`, `\$`, "`", "\\`", ).Replace(message) } return quote + message + quote } lazygit-0.50.0+ds1/pkg/commands/oscommands/cmd_obj_runner.go000066400000000000000000000253731500612110400240040ustar00rootroot00000000000000package oscommands import ( "bufio" "bytes" "io" "regexp" "strings" "time" "github.com/go-errors/errors" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sirupsen/logrus" ) type ICmdObjRunner interface { Run(cmdObj ICmdObj) error RunWithOutput(cmdObj ICmdObj) (string, error) RunWithOutputs(cmdObj ICmdObj) (string, string, error) RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error } type cmdObjRunner struct { log *logrus.Entry guiIO *guiIO } var _ ICmdObjRunner = &cmdObjRunner{} func (self *cmdObjRunner) Run(cmdObj ICmdObj) error { if cmdObj.Mutex() != nil { cmdObj.Mutex().Lock() defer cmdObj.Mutex().Unlock() } if cmdObj.GetCredentialStrategy() != NONE { return self.runWithCredentialHandling(cmdObj) } if cmdObj.ShouldStreamOutput() { return self.runAndStream(cmdObj) } _, err := self.RunWithOutputAux(cmdObj) return err } func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) { if cmdObj.Mutex() != nil { cmdObj.Mutex().Lock() defer cmdObj.Mutex().Unlock() } if cmdObj.GetCredentialStrategy() != NONE { err := self.runWithCredentialHandling(cmdObj) // for now we're not capturing output, just because it would take a little more // effort and there's currently no use case for it. Some commands call RunWithOutput // but ignore the output, hence why we've got this check here. return "", err } if cmdObj.ShouldStreamOutput() { err := self.runAndStream(cmdObj) // for now we're not capturing output, just because it would take a little more // effort and there's currently no use case for it. Some commands call RunWithOutput // but ignore the output, hence why we've got this check here. return "", err } return self.RunWithOutputAux(cmdObj) } func (self *cmdObjRunner) RunWithOutputs(cmdObj ICmdObj) (string, string, error) { if cmdObj.Mutex() != nil { cmdObj.Mutex().Lock() defer cmdObj.Mutex().Unlock() } if cmdObj.GetCredentialStrategy() != NONE { err := self.runWithCredentialHandling(cmdObj) // for now we're not capturing output, just because it would take a little more // effort and there's currently no use case for it. Some commands call RunWithOutputs // but ignore the output, hence why we've got this check here. return "", "", err } if cmdObj.ShouldStreamOutput() { err := self.runAndStream(cmdObj) // for now we're not capturing output, just because it would take a little more // effort and there's currently no use case for it. Some commands call RunWithOutputs // but ignore the output, hence why we've got this check here. return "", "", err } return self.RunWithOutputsAux(cmdObj) } func (self *cmdObjRunner) RunWithOutputAux(cmdObj ICmdObj) (string, error) { self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand") if cmdObj.ShouldLog() { self.logCmdObj(cmdObj) } t := time.Now() output, err := sanitisedCommandOutput(cmdObj.GetCmd().CombinedOutput()) if err != nil { self.log.WithField("command", cmdObj.ToString()).Error(output) } self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) return output, err } func (self *cmdObjRunner) RunWithOutputsAux(cmdObj ICmdObj) (string, string, error) { self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand") if cmdObj.ShouldLog() { self.logCmdObj(cmdObj) } t := time.Now() var outBuffer, errBuffer bytes.Buffer cmd := cmdObj.GetCmd() cmd.Stdout = &outBuffer cmd.Stderr = &errBuffer err := cmd.Run() self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) stdout := outBuffer.String() stderr, err := sanitisedCommandOutput(errBuffer.Bytes(), err) if err != nil { self.log.WithField("command", cmdObj.ToString()).Error(stderr) } return stdout, stderr, err } func (self *cmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error { if cmdObj.Mutex() != nil { cmdObj.Mutex().Lock() defer cmdObj.Mutex().Unlock() } if cmdObj.GetCredentialStrategy() != NONE { return errors.New("cannot call RunAndProcessLines with credential strategy. If you're seeing this then a contributor to Lazygit has accidentally called this method! Please raise an issue") } if cmdObj.ShouldLog() { self.logCmdObj(cmdObj) } t := time.Now() cmd := cmdObj.GetCmd() stdoutPipe, err := cmd.StdoutPipe() if err != nil { return err } scanner := bufio.NewScanner(stdoutPipe) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) if err := cmd.Start(); err != nil { return err } for scanner.Scan() { line := scanner.Text() stop, err := onLine(line) if err != nil { return err } if stop { _ = Kill(cmd) break } } if scanner.Err() != nil { _ = Kill(cmd) return scanner.Err() } _ = cmd.Wait() self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) return nil } func (self *cmdObjRunner) logCmdObj(cmdObj ICmdObj) { self.guiIO.logCommandFn(cmdObj.ToString(), true) } func sanitisedCommandOutput(output []byte, err error) (string, error) { outputString := string(output) if err != nil { // errors like 'exit status 1' are not very useful so we'll create an error // from the combined output if outputString == "" { return "", utils.WrapError(err) } return outputString, errors.New(outputString) } return outputString, nil } type cmdHandler struct { stdoutPipe io.Reader stdinPipe io.Writer close func() error } func (self *cmdObjRunner) runAndStream(cmdObj ICmdObj) error { return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) { go func() { _, _ = io.Copy(cmdWriter, handler.stdoutPipe) }() }) } func (self *cmdObjRunner) runAndStreamAux( cmdObj ICmdObj, onRun func(*cmdHandler, io.Writer), ) error { cmdWriter := self.guiIO.newCmdWriterFn() if cmdObj.ShouldLog() { self.logCmdObj(cmdObj) } self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand") cmd := cmdObj.GetCmd() var stderr bytes.Buffer cmd.Stderr = io.MultiWriter(cmdWriter, &stderr) handler, err := self.getCmdHandler(cmd) if err != nil { return err } var stdout bytes.Buffer handler.stdoutPipe = io.TeeReader(handler.stdoutPipe, &stdout) defer func() { if closeErr := handler.close(); closeErr != nil { self.log.Error(closeErr) } }() t := time.Now() onRun(handler, cmdWriter) err = cmd.Wait() self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) if err != nil { errStr := stderr.String() if errStr != "" { return errors.New(errStr) } if cmdObj.ShouldIgnoreEmptyError() { return nil } stdoutStr := stdout.String() if stdoutStr != "" { return errors.New(stdoutStr) } return errors.New("Command exited with non-zero exit code, but no output") } return nil } type CredentialType int const ( Password CredentialType = iota Username Passphrase PIN Token ) // Whenever we're asked for a password we just enter a newline, which will // eventually cause the command to fail. var failPromptFn = func(CredentialType) <-chan string { ch := make(chan string) go func() { ch <- "\n" }() return ch } func (self *cmdObjRunner) runWithCredentialHandling(cmdObj ICmdObj) error { promptFn, err := self.getCredentialPromptFn(cmdObj) if err != nil { return err } return self.runAndDetectCredentialRequest(cmdObj, promptFn) } func (self *cmdObjRunner) getCredentialPromptFn(cmdObj ICmdObj) (func(CredentialType) <-chan string, error) { switch cmdObj.GetCredentialStrategy() { case PROMPT: return self.guiIO.promptForCredentialFn, nil case FAIL: return failPromptFn, nil default: // we should never land here return nil, errors.New("runWithCredentialHandling called but cmdObj does not have a credential strategy") } } // runAndDetectCredentialRequest detect a username / password / passphrase question in a command // promptUserForCredential is a function that gets executed when this function detect you need to fill in a password or passphrase // The promptUserForCredential argument will be "username", "password" or "passphrase" and expects the user's password/passphrase or username back func (self *cmdObjRunner) runAndDetectCredentialRequest( cmdObj ICmdObj, promptUserForCredential func(CredentialType) <-chan string, ) error { // setting the output to english so we can parse it for a username/password request cmdObj.AddEnvVars("LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8") return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) { tr := io.TeeReader(handler.stdoutPipe, cmdWriter) go utils.Safe(func() { self.processOutput(tr, handler.stdinPipe, promptUserForCredential, cmdObj.GetTask()) }) }) } func (self *cmdObjRunner) processOutput( reader io.Reader, writer io.Writer, promptUserForCredential func(CredentialType) <-chan string, task gocui.Task, ) { checkForCredentialRequest := self.getCheckForCredentialRequestFunc() scanner := bufio.NewScanner(reader) scanner.Split(bufio.ScanBytes) for scanner.Scan() { newBytes := scanner.Bytes() askFor, ok := checkForCredentialRequest(newBytes) if ok { responseChan := promptUserForCredential(askFor) if task != nil { task.Pause() } toInput := <-responseChan if task != nil { task.Continue() } // If the return data is empty we don't write anything to stdin if toInput != "" { _, _ = writer.Write([]byte(toInput)) } } } } // having a function that returns a function because we need to maintain some state inbetween calls hence the closure func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (CredentialType, bool) { var ttyText strings.Builder prompts := map[string]CredentialType{ `Password:`: Password, `.+'s password:`: Password, `Password\s*for\s*'.+':`: Password, `Username\s*for\s*'.+':`: Username, `Enter\s*passphrase\s*for\s*key\s*'.+':`: Passphrase, `Enter\s*PIN\s*for\s*.+\s*key\s*.+:`: PIN, `.*2FA Token.*`: Token, } compiledPrompts := map[*regexp.Regexp]CredentialType{} for pattern, askFor := range prompts { compiledPattern := regexp.MustCompile(pattern) compiledPrompts[compiledPattern] = askFor } newlineRegex := regexp.MustCompile("\n") // this function takes each word of output from the command and builds up a string to see if we're being asked for a password return func(newBytes []byte) (CredentialType, bool) { _, err := ttyText.Write(newBytes) if err != nil { self.log.Error(err) } for pattern, askFor := range compiledPrompts { if match := pattern.Match([]byte(ttyText.String())); match { ttyText.Reset() return askFor, true } } if indices := newlineRegex.FindIndex([]byte(ttyText.String())); indices != nil { newText := []byte(ttyText.String()[indices[1]:]) ttyText.Reset() ttyText.Write(newText) } return 0, false } } lazygit-0.50.0+ds1/pkg/commands/oscommands/cmd_obj_runner_default.go000066400000000000000000000010001500612110400254650ustar00rootroot00000000000000//go:build !windows // +build !windows package oscommands import ( "os/exec" "github.com/creack/pty" ) // we define this separately for windows and non-windows given that windows does // not have great PTY support and we need a PTY to handle a credential request func (self *cmdObjRunner) getCmdHandler(cmd *exec.Cmd) (*cmdHandler, error) { ptmx, err := pty.Start(cmd) if err != nil { return nil, err } return &cmdHandler{ stdoutPipe: ptmx, stdinPipe: ptmx, close: ptmx.Close, }, nil } lazygit-0.50.0+ds1/pkg/commands/oscommands/cmd_obj_runner_test.go000066400000000000000000000070101500612110400250270ustar00rootroot00000000000000package oscommands import ( "strings" "testing" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/utils" ) func getRunner() *cmdObjRunner { log := utils.NewDummyLog() return &cmdObjRunner{ log: log, guiIO: NewNullGuiIO(log), } } func toChanFn(f func(ct CredentialType) string) func(CredentialType) <-chan string { return func(ct CredentialType) <-chan string { ch := make(chan string) go func() { ch <- f(ct) }() return ch } } func TestProcessOutput(t *testing.T) { defaultPromptUserForCredential := func(ct CredentialType) string { switch ct { case Password: return "password" case Username: return "username" case Passphrase: return "passphrase" case PIN: return "pin" case Token: return "token" default: panic("unexpected credential type") } } scenarios := []struct { name string promptUserForCredential func(CredentialType) string output string expectedToWrite string }{ { name: "no output", promptUserForCredential: defaultPromptUserForCredential, output: "", expectedToWrite: "", }, { name: "password prompt", promptUserForCredential: defaultPromptUserForCredential, output: "Password:", expectedToWrite: "password", }, { name: "password prompt 2", promptUserForCredential: defaultPromptUserForCredential, output: "Bill's password:", expectedToWrite: "password", }, { name: "password prompt 3", promptUserForCredential: defaultPromptUserForCredential, output: "Password for 'Bill':", expectedToWrite: "password", }, { name: "username prompt", promptUserForCredential: defaultPromptUserForCredential, output: "Username for 'Bill':", expectedToWrite: "username", }, { name: "passphrase prompt", promptUserForCredential: defaultPromptUserForCredential, output: "Enter passphrase for key '123':", expectedToWrite: "passphrase", }, { name: "pin prompt", promptUserForCredential: defaultPromptUserForCredential, output: "Enter PIN for key '123':", expectedToWrite: "pin", }, { name: "2FA token prompt", promptUserForCredential: defaultPromptUserForCredential, output: "testuser 2FA Token (citadel)", expectedToWrite: "token", }, { name: "username and password prompt", promptUserForCredential: defaultPromptUserForCredential, output: "Password:\nUsername for 'Alice':\n", expectedToWrite: "passwordusername", }, { name: "user submits empty credential", promptUserForCredential: func(ct CredentialType) string { return "" }, output: "Password:\n", expectedToWrite: "", }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { runner := getRunner() reader := strings.NewReader(scenario.output) writer := &strings.Builder{} task := gocui.NewFakeTask() runner.processOutput(reader, writer, toChanFn(scenario.promptUserForCredential), task) if writer.String() != scenario.expectedToWrite { t.Errorf("expected to write '%s' but got '%s'", scenario.expectedToWrite, writer.String()) } }) } } lazygit-0.50.0+ds1/pkg/commands/oscommands/cmd_obj_runner_win.go000066400000000000000000000024051500612110400246500ustar00rootroot00000000000000//go:build windows // +build windows package oscommands import ( "bytes" "io" "os/exec" "github.com/sasha-s/go-deadlock" ) type Buffer struct { b bytes.Buffer m deadlock.Mutex } func (b *Buffer) Read(p []byte) (n int, err error) { b.m.Lock() defer b.m.Unlock() return b.b.Read(p) } func (b *Buffer) Write(p []byte) (n int, err error) { b.m.Lock() defer b.m.Unlock() return b.b.Write(p) } // TODO: Remove this hack and replace it with a proper way to run commands live on windows. We still have an issue where if a password is requested, the request for a password is written straight to stdout because we can't control the stdout of a subprocess of a subprocess. Keep an eye on https://github.com/creack/pty/pull/109 func (self *cmdObjRunner) getCmdHandler(cmd *exec.Cmd) (*cmdHandler, error) { stdoutReader, stdoutWriter := io.Pipe() cmd.Stdout = stdoutWriter buf := &Buffer{} cmd.Stdin = buf if err := cmd.Start(); err != nil { return nil, err } // because we don't yet have windows support for a pty, we instead just // pass our standard stream handlers and because there's no pty to close // we pass a no-op function for that. return &cmdHandler{ stdoutPipe: stdoutReader, stdinPipe: buf, close: func() error { return nil }, }, nil } lazygit-0.50.0+ds1/pkg/commands/oscommands/cmd_obj_test.go000066400000000000000000000021411500612110400234360ustar00rootroot00000000000000package oscommands import ( "os/exec" "testing" "github.com/jesseduffield/gocui" ) func TestCmdObjToString(t *testing.T) { quote := func(s string) string { return "\"" + s + "\"" } scenarios := []struct { cmdArgs []string expected string }{ { cmdArgs: []string{"git", "push", "myfile.txt"}, expected: "git push myfile.txt", }, { cmdArgs: []string{"git", "push", "my file.txt"}, expected: "git push \"my file.txt\"", }, } for _, scenario := range scenarios { cmd := exec.Command(scenario.cmdArgs[0], scenario.cmdArgs[1:]...) cmdObj := &CmdObj{cmd: cmd} actual := cmdObj.ToString() if actual != scenario.expected { t.Errorf("Expected %s, got %s", quote(scenario.expected), quote(actual)) } } } func TestClone(t *testing.T) { task := gocui.NewFakeTask() cmdObj := &CmdObj{task: task, cmd: &exec.Cmd{}} clone := cmdObj.Clone() if clone == cmdObj { t.Errorf("Clone should not return the same object") } if clone.GetTask() == nil { t.Errorf("Clone task should not be nil") } if clone.GetTask() != task { t.Errorf("Clone should have the same task") } } lazygit-0.50.0+ds1/pkg/commands/oscommands/copy.go000066400000000000000000000067641500612110400217730ustar00rootroot00000000000000package oscommands import ( "errors" "io" "os" "path/filepath" ) /* MIT License * * Copyright (c) 2017 Roland Singer [roland.singer@desertbit.com] * * 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. */ // CopyFile copies the contents of the file named src to the file named // by dst. The file will be created if it does not already exist. If the // destination file exists, all it's contents will be replaced by the contents // of the source file. The file mode will be copied from the source and // the copied data is synced/flushed to stable storage. func CopyFile(src, dst string) (err error) { in, err := os.Open(src) if err != nil { return //nolint: nakedret } defer in.Close() out, err := os.Create(dst) if err != nil { return //nolint: nakedret } defer func() { if e := out.Close(); e != nil { err = e } }() _, err = io.Copy(out, in) if err != nil { return //nolint: nakedret } err = out.Sync() if err != nil { return //nolint: nakedret } si, err := os.Stat(src) if err != nil { return //nolint: nakedret } err = os.Chmod(dst, si.Mode()) if err != nil { return //nolint: nakedret } return //nolint: nakedret } // CopyDir recursively copies a directory tree, attempting to preserve permissions. // Source directory must exist. If destination already exists we'll clobber it. // Symlinks are ignored and skipped. func CopyDir(src string, dst string) (err error) { src = filepath.Clean(src) dst = filepath.Clean(dst) si, err := os.Stat(src) if err != nil { return err } if !si.IsDir() { return errors.New("source is not a directory") } _, err = os.Stat(dst) if err != nil && !os.IsNotExist(err) { return //nolint: nakedret } if err == nil { // it exists so let's remove it if err := os.RemoveAll(dst); err != nil { return err } } err = os.MkdirAll(dst, si.Mode()) if err != nil { return //nolint: nakedret } entries, err := os.ReadDir(src) if err != nil { return //nolint: nakedret } for _, entry := range entries { srcPath := filepath.Join(src, entry.Name()) dstPath := filepath.Join(dst, entry.Name()) if entry.IsDir() { err = CopyDir(srcPath, dstPath) if err != nil { return //nolint: nakedret } } else { var info os.FileInfo info, err = entry.Info() if err != nil { return //nolint: nakedret } // Skip symlinks. if info.Mode()&os.ModeSymlink != 0 { continue } err = CopyFile(srcPath, dstPath) if err != nil { return //nolint: nakedret } } } return //nolint: nakedret } lazygit-0.50.0+ds1/pkg/commands/oscommands/dummies.go000066400000000000000000000031771500612110400224570ustar00rootroot00000000000000package oscommands import ( "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/utils" ) // NewDummyOSCommand creates a new dummy OSCommand for testing func NewDummyOSCommand() *OSCommand { osCmd := NewOSCommand(utils.NewDummyCommon(), config.NewDummyAppConfig(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog())) return osCmd } type OSCommandDeps struct { Common *common.Common Platform *Platform GetenvFn func(string) string RemoveFileFn func(string) error Cmd *CmdObjBuilder TempDir string } func NewDummyOSCommandWithDeps(deps OSCommandDeps) *OSCommand { common := deps.Common if common == nil { common = utils.NewDummyCommon() } platform := deps.Platform if platform == nil { platform = dummyPlatform } return &OSCommand{ Common: common, Platform: platform, getenvFn: deps.GetenvFn, removeFileFn: deps.RemoveFileFn, guiIO: NewNullGuiIO(utils.NewDummyLog()), tempDir: deps.TempDir, } } func NewDummyCmdObjBuilder(runner ICmdObjRunner) *CmdObjBuilder { return &CmdObjBuilder{ runner: runner, platform: dummyPlatform, } } var dummyPlatform = &Platform{ OS: "darwin", Shell: "bash", ShellArg: "-c", OpenCommand: "open {{filename}}", OpenLinkCommand: "open {{link}}", } func NewDummyOSCommandWithRunner(runner *FakeCmdObjRunner) *OSCommand { osCommand := NewOSCommand(utils.NewDummyCommon(), config.NewDummyAppConfig(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog())) osCommand.Cmd = NewDummyCmdObjBuilder(runner) return osCommand } lazygit-0.50.0+ds1/pkg/commands/oscommands/fake_cmd_obj_runner.go000066400000000000000000000073671500612110400247750ustar00rootroot00000000000000package oscommands import ( "bufio" "fmt" "strings" "sync" "testing" "github.com/go-errors/errors" "github.com/samber/lo" "golang.org/x/exp/slices" ) // for use in testing type FakeCmdObjRunner struct { t *testing.T // commands can be run in any order; mimicking the concurrent behaviour of // production code. expectedCmds []CmdObjMatcher invokedCmdIndexes []int mutex sync.Mutex } type CmdObjMatcher struct { description string // returns true if the matcher matches the command object test func(ICmdObj) bool // output of the command output string // error of the command err error } var _ ICmdObjRunner = &FakeCmdObjRunner{} func NewFakeRunner(t *testing.T) *FakeCmdObjRunner { //nolint:thelper return &FakeCmdObjRunner{t: t} } func (self *FakeCmdObjRunner) remainingExpectedCmds() []CmdObjMatcher { return lo.Filter(self.expectedCmds, func(_ CmdObjMatcher, i int) bool { return !lo.Contains(self.invokedCmdIndexes, i) }) } func (self *FakeCmdObjRunner) Run(cmdObj ICmdObj) error { _, err := self.RunWithOutput(cmdObj) return err } func (self *FakeCmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) { self.mutex.Lock() defer self.mutex.Unlock() if len(self.remainingExpectedCmds()) == 0 { self.t.Errorf("ran too many commands. Unexpected command: `%s`", cmdObj.ToString()) return "", errors.New("ran too many commands") } for i := range self.expectedCmds { if lo.Contains(self.invokedCmdIndexes, i) { continue } expectedCmd := self.expectedCmds[i] matched := expectedCmd.test(cmdObj) if matched { self.invokedCmdIndexes = append(self.invokedCmdIndexes, i) return expectedCmd.output, expectedCmd.err } } self.t.Errorf("Unexpected command: `%s`", cmdObj.ToString()) return "", nil } func (self *FakeCmdObjRunner) RunWithOutputs(cmdObj ICmdObj) (string, string, error) { output, err := self.RunWithOutput(cmdObj) return output, "", err } func (self *FakeCmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error { output, err := self.RunWithOutput(cmdObj) if err != nil { return err } scanner := bufio.NewScanner(strings.NewReader(output)) scanner.Split(bufio.ScanLines) for scanner.Scan() { line := scanner.Text() stop, err := onLine(line) if err != nil { return err } if stop { break } } return nil } func (self *FakeCmdObjRunner) ExpectFunc(description string, fn func(cmdObj ICmdObj) bool, output string, err error) *FakeCmdObjRunner { self.mutex.Lock() defer self.mutex.Unlock() self.expectedCmds = append(self.expectedCmds, CmdObjMatcher{ test: fn, output: output, err: err, description: description, }) return self } func (self *FakeCmdObjRunner) ExpectArgs(expectedArgs []string, output string, err error) *FakeCmdObjRunner { description := fmt.Sprintf("matches args %s", strings.Join(expectedArgs, " ")) self.ExpectFunc(description, func(cmdObj ICmdObj) bool { return slices.Equal(expectedArgs, cmdObj.GetCmd().Args) }, output, err) return self } func (self *FakeCmdObjRunner) ExpectGitArgs(expectedArgs []string, output string, err error) *FakeCmdObjRunner { description := fmt.Sprintf("matches git args %s", strings.Join(expectedArgs, " ")) self.ExpectFunc(description, func(cmdObj ICmdObj) bool { return slices.Equal(expectedArgs, cmdObj.GetCmd().Args[1:]) }, output, err) return self } func (self *FakeCmdObjRunner) CheckForMissingCalls() { self.mutex.Lock() defer self.mutex.Unlock() remaining := self.remainingExpectedCmds() if len(remaining) > 0 { self.t.Errorf( "expected %d more command(s) to be run. Remaining commands:\n%s", len(remaining), strings.Join( lo.Map(remaining, func(cmdObj CmdObjMatcher, _ int) string { return cmdObj.description }), "\n", ), ) } } lazygit-0.50.0+ds1/pkg/commands/oscommands/gui_io.go000066400000000000000000000037621500612110400222670ustar00rootroot00000000000000package oscommands import ( "io" "github.com/sirupsen/logrus" ) // this struct captures some IO stuff type guiIO struct { // this is for logging anything we want. It'll be written to a log file for the sake // of debugging. log *logrus.Entry // this is for us to log the command we're about to run e.g. 'git push'. The GUI // will write this to a log panel so that the user can see which commands are being // run. // The isCommandLineCommand arg is there so that we can style the log differently // depending on whether we're directly outputting a command we're about to run that // will be run on the command line, or if we're using something from Go's standard lib. logCommandFn func(str string, isCommandLineCommand bool) // this is for us to directly write the output of a command. We will do this for // certain commands like 'git push'. The GUI will write this to a command output panel. // We need a new cmd writer per command, hence it being a function. newCmdWriterFn func() io.Writer // this allows us to request info from the user like username/password, in the event // that a command requests it. // the 'credential' arg is something like 'username' or 'password' promptForCredentialFn func(credential CredentialType) <-chan string } func NewGuiIO( log *logrus.Entry, logCommandFn func(string, bool), newCmdWriterFn func() io.Writer, promptForCredentialFn func(CredentialType) <-chan string, ) *guiIO { return &guiIO{ log: log, logCommandFn: logCommandFn, newCmdWriterFn: newCmdWriterFn, promptForCredentialFn: promptForCredentialFn, } } // we use this function when we want to access the functionality of our OS struct but we // don't have anywhere to log things, or request input from the user. func NewNullGuiIO(log *logrus.Entry) *guiIO { return &guiIO{ log: log, logCommandFn: func(string, bool) {}, newCmdWriterFn: func() io.Writer { return io.Discard }, promptForCredentialFn: failPromptFn, } } lazygit-0.50.0+ds1/pkg/commands/oscommands/os.go000066400000000000000000000216711500612110400214340ustar00rootroot00000000000000package oscommands import ( "fmt" "io" "os" "os/exec" "path/filepath" "strings" "sync" "github.com/go-errors/errors" "github.com/samber/lo" "github.com/atotto/clipboard" "github.com/jesseduffield/kill" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/utils" ) // OSCommand holds all the os commands type OSCommand struct { *common.Common Platform *Platform getenvFn func(string) string guiIO *guiIO removeFileFn func(string) error Cmd *CmdObjBuilder tempDir string } // Platform stores the os state type Platform struct { OS string Shell string ShellArg string PrefixForShellFunctionsFile string OpenCommand string OpenLinkCommand string } // NewOSCommand os command runner func NewOSCommand(common *common.Common, config config.AppConfigurer, platform *Platform, guiIO *guiIO) *OSCommand { c := &OSCommand{ Common: common, Platform: platform, getenvFn: os.Getenv, removeFileFn: os.RemoveAll, guiIO: guiIO, tempDir: config.GetTempDir(), } runner := &cmdObjRunner{log: common.Log, guiIO: guiIO} c.Cmd = &CmdObjBuilder{runner: runner, platform: platform} return c } func (c *OSCommand) LogCommand(cmdStr string, commandLine bool) { c.Log.WithField("command", cmdStr).Info("RunCommand") c.guiIO.logCommandFn(cmdStr, commandLine) } // FileType tells us if the file is a file, directory or other func FileType(path string) string { fileInfo, err := os.Stat(path) if err != nil { return "other" } if fileInfo.IsDir() { return "directory" } return "file" } func (c *OSCommand) OpenFile(filename string) error { commandTemplate := c.UserConfig().OS.Open if commandTemplate == "" { // Legacy support commandTemplate = c.UserConfig().OS.OpenCommand } if commandTemplate == "" { commandTemplate = config.GetPlatformDefaultConfig().Open } templateValues := map[string]string{ "filename": c.Quote(filename), } command := utils.ResolvePlaceholderString(commandTemplate, templateValues) return c.Cmd.NewShell(command, c.UserConfig().OS.ShellFunctionsFile).Run() } func (c *OSCommand) OpenLink(link string) error { commandTemplate := c.UserConfig().OS.OpenLink if commandTemplate == "" { // Legacy support commandTemplate = c.UserConfig().OS.OpenLinkCommand } if commandTemplate == "" { commandTemplate = config.GetPlatformDefaultConfig().OpenLink } templateValues := map[string]string{ "link": c.Quote(link), } command := utils.ResolvePlaceholderString(commandTemplate, templateValues) return c.Cmd.NewShell(command, c.UserConfig().OS.ShellFunctionsFile).Run() } // Quote wraps a message in platform-specific quotation marks func (c *OSCommand) Quote(message string) string { return c.Cmd.Quote(message) } // AppendLineToFile adds a new line in file func (c *OSCommand) AppendLineToFile(filename, line string) error { msg := utils.ResolvePlaceholderString( c.Tr.Log.AppendingLineToFile, map[string]string{ "line": line, "filename": filename, }, ) c.LogCommand(msg, false) f, err := os.OpenFile(filename, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0o600) if err != nil { return utils.WrapError(err) } defer f.Close() info, err := os.Stat(filename) if err != nil { return utils.WrapError(err) } if info.Size() > 0 { // read last char buf := make([]byte, 1) if _, err := f.ReadAt(buf, info.Size()-1); err != nil { return utils.WrapError(err) } // if the last byte of the file is not a newline, add it if []byte("\n")[0] != buf[0] { _, err = f.WriteString("\n") } } if err == nil { _, err = f.WriteString(line + "\n") } if err != nil { return utils.WrapError(err) } return nil } // CreateFileWithContent creates a file with the given content func (c *OSCommand) CreateFileWithContent(path string, content string) error { msg := utils.ResolvePlaceholderString( c.Tr.Log.CreateFileWithContent, map[string]string{ "path": path, }, ) c.LogCommand(msg, false) if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { c.Log.Error(err) return err } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { c.Log.Error(err) return utils.WrapError(err) } return nil } // Remove removes a file or directory at the specified path func (c *OSCommand) Remove(filename string) error { msg := utils.ResolvePlaceholderString( c.Tr.Log.Remove, map[string]string{ "filename": filename, }, ) c.LogCommand(msg, false) err := os.RemoveAll(filename) return utils.WrapError(err) } // FileExists checks whether a file exists at the specified path func (c *OSCommand) FileExists(path string) (bool, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { return false, nil } return false, err } return true, nil } // PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C func (c *OSCommand) PipeCommands(cmdObjs ...ICmdObj) error { cmds := lo.Map(cmdObjs, func(cmdObj ICmdObj, _ int) *exec.Cmd { return cmdObj.GetCmd() }) logCmdStr := strings.Join( lo.Map(cmdObjs, func(cmdObj ICmdObj, _ int) string { return cmdObj.ToString() }), " | ", ) c.LogCommand(logCmdStr, true) for i := 0; i < len(cmds)-1; i++ { stdout, err := cmds[i].StdoutPipe() if err != nil { return err } cmds[i+1].Stdin = stdout } // keeping this here in case I adapt this code for some other purpose in the future // cmds[len(cmds)-1].Stdout = os.Stdout finalErrors := []string{} wg := sync.WaitGroup{} wg.Add(len(cmds)) for _, cmd := range cmds { go utils.Safe(func() { stderr, err := cmd.StderrPipe() if err != nil { c.Log.Error(err) } if err := cmd.Start(); err != nil { c.Log.Error(err) } if b, err := io.ReadAll(stderr); err == nil { if len(b) > 0 { finalErrors = append(finalErrors, string(b)) } } if err := cmd.Wait(); err != nil { c.Log.Error(err) } wg.Done() }) } wg.Wait() if len(finalErrors) > 0 { return errors.New(strings.Join(finalErrors, "\n")) } return nil } // Kill kills a process. If the process has Setpgid == true, then we have anticipated that it might spawn its own child processes, so we've given it a process group ID (PGID) equal to its process id (PID) and given its child processes will inherit the PGID, we can kill that group, rather than killing the process itself. func Kill(cmd *exec.Cmd) error { return kill.Kill(cmd) } // PrepareForChildren sets Setpgid to true on the cmd, so that when we run it as a subprocess, we can kill its group rather than the process itself. This is because some commands, like `docker-compose logs` spawn multiple children processes, and killing the parent process isn't sufficient for killing those child processes. We set the group id here, and then in subprocess.go we check if the group id is set and if so, we kill the whole group rather than just the one process. func PrepareForChildren(cmd *exec.Cmd) { kill.PrepareForChildren(cmd) } func (c *OSCommand) CopyToClipboard(str string) error { escaped := strings.Replace(str, "\n", "\\n", -1) truncated := utils.TruncateWithEllipsis(escaped, 40) msg := utils.ResolvePlaceholderString( c.Tr.Log.CopyToClipboard, map[string]string{ "str": truncated, }, ) c.LogCommand(msg, false) if c.UserConfig().OS.CopyToClipboardCmd != "" { cmdStr := utils.ResolvePlaceholderString(c.UserConfig().OS.CopyToClipboardCmd, map[string]string{ "text": c.Cmd.Quote(str), }) return c.Cmd.NewShell(cmdStr, c.UserConfig().OS.ShellFunctionsFile).Run() } return clipboard.WriteAll(str) } func (c *OSCommand) PasteFromClipboard() (string, error) { var s string var err error if c.UserConfig().OS.CopyToClipboardCmd != "" { cmdStr := c.UserConfig().OS.ReadFromClipboardCmd s, err = c.Cmd.NewShell(cmdStr, c.UserConfig().OS.ShellFunctionsFile).RunWithOutput() } else { s, err = clipboard.ReadAll() } if err != nil { return "", err } return strings.ReplaceAll(s, "\r\n", "\n"), nil } func (c *OSCommand) RemoveFile(path string) error { msg := utils.ResolvePlaceholderString( c.Tr.Log.RemoveFile, map[string]string{ "path": path, }, ) c.LogCommand(msg, false) return c.removeFileFn(path) } func (c *OSCommand) Getenv(key string) string { return c.getenvFn(key) } func (c *OSCommand) GetTempDir() string { return c.tempDir } // GetLazygitPath returns the path of the currently executed file func GetLazygitPath() string { ex, err := os.Executable() // get the executable path for git to use if err != nil { ex = os.Args[0] // fallback to the first call argument if needed } return `"` + filepath.ToSlash(ex) + `"` } func (c *OSCommand) UpdateWindowTitle() error { if c.Platform.OS != "windows" { return nil } path, getWdErr := os.Getwd() if getWdErr != nil { return getWdErr } argString := fmt.Sprint("title ", filepath.Base(path), " - Lazygit") return c.Cmd.NewShell(argString, c.UserConfig().OS.ShellFunctionsFile).Run() } lazygit-0.50.0+ds1/pkg/commands/oscommands/os_default_platform.go000066400000000000000000000013101500612110400250300ustar00rootroot00000000000000//go:build !windows // +build !windows package oscommands import ( "os" "runtime" "strings" ) func GetPlatform() *Platform { shell := getUserShell() prefixForShellFunctionsFile := "" if strings.HasSuffix(shell, "bash") { prefixForShellFunctionsFile = "shopt -s expand_aliases\n" } return &Platform{ OS: runtime.GOOS, Shell: shell, ShellArg: "-c", PrefixForShellFunctionsFile: prefixForShellFunctionsFile, OpenCommand: "open {{filename}}", OpenLinkCommand: "open {{link}}", } } func getUserShell() string { if shell := os.Getenv("SHELL"); shell != "" { return shell } return "bash" } lazygit-0.50.0+ds1/pkg/commands/oscommands/os_default_test.go000066400000000000000000000062751500612110400242020ustar00rootroot00000000000000//go:build !windows // +build !windows package oscommands import ( "testing" "github.com/go-errors/errors" "github.com/stretchr/testify/assert" ) func TestOSCommandRunWithOutput(t *testing.T) { type scenario struct { args []string test func(string, error) } scenarios := []scenario{ { []string{"echo", "-n", "123"}, func(output string, err error) { assert.NoError(t, err) assert.EqualValues(t, "123", output) }, }, { []string{"rmdir", "unexisting-folder"}, func(output string, err error) { assert.Regexp(t, "rmdir.*unexisting-folder.*", err.Error()) }, }, } for _, s := range scenarios { c := NewDummyOSCommand() s.test(c.Cmd.New(s.args).RunWithOutput()) } } func TestOSCommandOpenFileDarwin(t *testing.T) { type scenario struct { filename string runner *FakeCmdObjRunner test func(error) } scenarios := []scenario{ { filename: "test", runner: NewFakeRunner(t). ExpectArgs([]string{"bash", "-c", `open "test"`}, "", errors.New("error")), test: func(err error) { assert.Error(t, err) }, }, { filename: "test", runner: NewFakeRunner(t). ExpectArgs([]string{"bash", "-c", `open "test"`}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { filename: "filename with spaces", runner: NewFakeRunner(t). ExpectArgs([]string{"bash", "-c", `open "filename with spaces"`}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { oSCmd := NewDummyOSCommandWithRunner(s.runner) oSCmd.Platform.OS = "darwin" oSCmd.UserConfig().OS.Open = "open {{filename}}" s.test(oSCmd.OpenFile(s.filename)) } } // TestOSCommandOpenFileLinux tests the OpenFile command on Linux func TestOSCommandOpenFileLinux(t *testing.T) { type scenario struct { filename string runner *FakeCmdObjRunner test func(error) } scenarios := []scenario{ { filename: "test", runner: NewFakeRunner(t). ExpectArgs([]string{"bash", "-c", `xdg-open "test" > /dev/null`}, "", errors.New("error")), test: func(err error) { assert.Error(t, err) }, }, { filename: "test", runner: NewFakeRunner(t). ExpectArgs([]string{"bash", "-c", `xdg-open "test" > /dev/null`}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { filename: "filename with spaces", runner: NewFakeRunner(t). ExpectArgs([]string{"bash", "-c", `xdg-open "filename with spaces" > /dev/null`}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { filename: "let's_test_with_single_quote", runner: NewFakeRunner(t). ExpectArgs([]string{"bash", "-c", `xdg-open "let's_test_with_single_quote" > /dev/null`}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { filename: "$USER.txt", runner: NewFakeRunner(t). ExpectArgs([]string{"bash", "-c", `xdg-open "\$USER.txt" > /dev/null`}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { oSCmd := NewDummyOSCommandWithRunner(s.runner) oSCmd.Platform.OS = "linux" oSCmd.UserConfig().OS.Open = `xdg-open {{filename}} > /dev/null` s.test(oSCmd.OpenFile(s.filename)) } } lazygit-0.50.0+ds1/pkg/commands/oscommands/os_test.go000066400000000000000000000075161500612110400224750ustar00rootroot00000000000000package oscommands import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestOSCommandRun(t *testing.T) { type scenario struct { args []string test func(error) } scenarios := []scenario{ { []string{"rmdir", "unexisting-folder"}, func(err error) { assert.Regexp(t, "rmdir.*unexisting-folder.*", err.Error()) }, }, } for _, s := range scenarios { c := NewDummyOSCommand() s.test(c.Cmd.New(s.args).Run()) } } func TestOSCommandQuote(t *testing.T) { osCommand := NewDummyOSCommand() osCommand.Platform.OS = "linux" actual := osCommand.Quote("hello `test`") expected := "\"hello \\`test\\`\"" assert.EqualValues(t, expected, actual) } // TestOSCommandQuoteSingleQuote tests the quote function with ' quotes explicitly for Linux func TestOSCommandQuoteSingleQuote(t *testing.T) { osCommand := NewDummyOSCommand() osCommand.Platform.OS = "linux" actual := osCommand.Quote("hello 'test'") expected := `"hello 'test'"` assert.EqualValues(t, expected, actual) } // TestOSCommandQuoteDoubleQuote tests the quote function with " quotes explicitly for Linux func TestOSCommandQuoteDoubleQuote(t *testing.T) { osCommand := NewDummyOSCommand() osCommand.Platform.OS = "linux" actual := osCommand.Quote(`hello "test"`) expected := `"hello \"test\""` assert.EqualValues(t, expected, actual) } // TestOSCommandQuoteWindows tests the quote function for Windows func TestOSCommandQuoteWindows(t *testing.T) { osCommand := NewDummyOSCommand() osCommand.Platform.OS = "windows" actual := osCommand.Quote(`hello "test" 'test2'`) expected := `\"hello "'"'"test"'"'" 'test2'\"` assert.EqualValues(t, expected, actual) } func TestOSCommandFileType(t *testing.T) { type scenario struct { path string setup func() test func(string) } scenarios := []scenario{ { "testFile", func() { if _, err := os.Create("testFile"); err != nil { panic(err) } }, func(output string) { assert.EqualValues(t, "file", output) }, }, { "file with spaces", func() { if _, err := os.Create("file with spaces"); err != nil { panic(err) } }, func(output string) { assert.EqualValues(t, "file", output) }, }, { "testDirectory", func() { if err := os.Mkdir("testDirectory", 0o644); err != nil { panic(err) } }, func(output string) { assert.EqualValues(t, "directory", output) }, }, { "nonExistent", func() {}, func(output string) { assert.EqualValues(t, "other", output) }, }, } for _, s := range scenarios { s.setup() s.test(FileType(s.path)) _ = os.RemoveAll(s.path) } } func TestOSCommandAppendLineToFile(t *testing.T) { type scenario struct { path string setup func(string) test func(string) } scenarios := []scenario{ { filepath.Join(os.TempDir(), "testFile"), func(path string) { if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { panic(err) } }, func(output string) { assert.EqualValues(t, "hello\nworld\n", output) }, }, { filepath.Join(os.TempDir(), "emptyTestFile"), func(path string) { if err := os.WriteFile(path, []byte(""), 0o600); err != nil { panic(err) } }, func(output string) { assert.EqualValues(t, "world\n", output) }, }, { filepath.Join(os.TempDir(), "testFileWithNewline"), func(path string) { if err := os.WriteFile(path, []byte("hello\n"), 0o600); err != nil { panic(err) } }, func(output string) { assert.EqualValues(t, "hello\nworld\n", output) }, }, } for _, s := range scenarios { s.setup(s.path) osCommand := NewDummyOSCommand() if err := osCommand.AppendLineToFile(s.path, "world"); err != nil { panic(err) } f, err := os.ReadFile(s.path) if err != nil { panic(err) } s.test(string(f)) _ = os.RemoveAll(s.path) } } lazygit-0.50.0+ds1/pkg/commands/oscommands/os_windows.go000066400000000000000000000002071500612110400231760ustar00rootroot00000000000000package oscommands func GetPlatform() *Platform { return &Platform{ OS: "windows", Shell: "cmd", ShellArg: "/c", } } lazygit-0.50.0+ds1/pkg/commands/oscommands/os_windows_test.go000066400000000000000000000034221500612110400242370ustar00rootroot00000000000000//go:build windows // +build windows package oscommands import ( "testing" "github.com/go-errors/errors" "github.com/stretchr/testify/assert" ) // handling this in a separate file because str.ToArgv has different behaviour if we're on windows func TestOSCommandOpenFileWindows(t *testing.T) { type scenario struct { filename string runner *FakeCmdObjRunner test func(error) } scenarios := []scenario{ { filename: "test", runner: NewFakeRunner(t). ExpectArgs([]string{"cmd", "/c", "start", "", "test"}, "", errors.New("error")), test: func(err error) { assert.Error(t, err) }, }, { filename: "test", runner: NewFakeRunner(t). ExpectArgs([]string{"cmd", "/c", "start", "", "test"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { filename: "filename with spaces", runner: NewFakeRunner(t). ExpectArgs([]string{"cmd", "/c", "start", "", "filename with spaces"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { filename: "let's_test_with_single_quote", runner: NewFakeRunner(t). ExpectArgs([]string{"cmd", "/c", "start", "", "let's_test_with_single_quote"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { filename: "$USER.txt", runner: NewFakeRunner(t). ExpectArgs([]string{"cmd", "/c", "start", "", "$USER.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { oSCmd := NewDummyOSCommandWithRunner(s.runner) platform := &Platform{ OS: "windows", Shell: "cmd", ShellArg: "/c", } oSCmd.Platform = platform oSCmd.Cmd.platform = platform oSCmd.UserConfig().OS.OpenCommand = `start "" {{filename}}` s.test(oSCmd.OpenFile(s.filename)) } } lazygit-0.50.0+ds1/pkg/commands/patch/000077500000000000000000000000001500612110400174115ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/patch/format.go000066400000000000000000000072071500612110400212360ustar00rootroot00000000000000package patch import ( "strings" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" ) type patchPresenter struct { patch *Patch // if true, all following fields are ignored plain bool // line indices for tagged lines (e.g. lines added to a custom patch) incLineIndices *set.Set[int] } // formats the patch as a plain string func formatPlain(patch *Patch) string { presenter := &patchPresenter{ patch: patch, plain: true, incLineIndices: set.New[int](), } return presenter.format() } func formatRangePlain(patch *Patch, startIdx int, endIdx int) string { lines := patch.Lines()[startIdx : endIdx+1] return strings.Join( lo.Map(lines, func(line *PatchLine, _ int) string { return line.Content + "\n" }), "", ) } type FormatViewOpts struct { // line indices for tagged lines (e.g. lines added to a custom patch) IncLineIndices *set.Set[int] } // formats the patch for rendering within a view, meaning it's coloured and // highlights selected items func formatView(patch *Patch, opts FormatViewOpts) string { includedLineIndices := opts.IncLineIndices if includedLineIndices == nil { includedLineIndices = set.New[int]() } presenter := &patchPresenter{ patch: patch, plain: false, incLineIndices: includedLineIndices, } return presenter.format() } func (self *patchPresenter) format() string { // if we have no changes in our patch (i.e. no additions or deletions) then // the patch is effectively empty and we can return an empty string if !self.patch.ContainsChanges() { return "" } stringBuilder := &strings.Builder{} lineIdx := 0 appendLine := func(line string) { _, _ = stringBuilder.WriteString(line + "\n") lineIdx++ } appendFormattedLine := func(line string, style style.TextStyle) { formattedLine := self.formatLine( line, style, lineIdx, ) appendLine(formattedLine) } for _, line := range self.patch.header { appendFormattedLine(line, theme.DefaultTextColor.SetBold()) } for _, hunk := range self.patch.hunks { appendLine( self.formatLine( hunk.formatHeaderStart(), style.FgCyan, lineIdx, ) + // we're splitting the line into two parts: the diff header and the context // We explicitly pass 'included' as false here so that we're only tagging the // first half of the line as included if the line is indeed included. self.formatLineAux( hunk.headerContext, theme.DefaultTextColor, false, ), ) for _, line := range hunk.bodyLines { appendFormattedLine(line.Content, self.patchLineStyle(line)) } } return stringBuilder.String() } func (self *patchPresenter) patchLineStyle(patchLine *PatchLine) style.TextStyle { switch patchLine.Kind { case ADDITION: return style.FgGreen case DELETION: return style.FgRed default: return theme.DefaultTextColor } } func (self *patchPresenter) formatLine(str string, textStyle style.TextStyle, index int) string { included := self.incLineIndices.Includes(index) return self.formatLineAux(str, textStyle, included) } // 'selected' means you've got it highlighted with your cursor // 'included' means the line has been included in the patch (only applicable when // building a patch) func (self *patchPresenter) formatLineAux(str string, textStyle style.TextStyle, included bool) string { if self.plain { return str } firstCharStyle := textStyle if included { firstCharStyle = firstCharStyle.MergeStyle(style.BgGreen) } if len(str) < 2 { return firstCharStyle.Sprint(str) } return firstCharStyle.Sprint(str[:1]) + textStyle.Sprint(str[1:]) } lazygit-0.50.0+ds1/pkg/commands/patch/hunk.go000066400000000000000000000044221500612110400207070ustar00rootroot00000000000000package patch import "fmt" // Example hunk: // @@ -16,2 +14,3 @@ func (f *CommitFile) Description() string { // return f.Name // -} // + // +// test type Hunk struct { // the line number of the first line in the old file ('16' in the above example) oldStart int // the line number of the first line in the new file ('14' in the above example) newStart int // the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example) headerContext string // the body of the hunk, excluding the header line bodyLines []*PatchLine } // Returns the number of lines in the hunk in the original file ('2' in the above example) func (self *Hunk) oldLength() int { return nLinesWithKind(self.bodyLines, []PatchLineKind{CONTEXT, DELETION}) } // Returns the number of lines in the hunk in the new file ('3' in the above example) func (self *Hunk) newLength() int { return nLinesWithKind(self.bodyLines, []PatchLineKind{CONTEXT, ADDITION}) } // Returns true if the hunk contains any changes (i.e. if it's not just a context hunk). // We'll end up with a context hunk if we're transforming a patch and one of the hunks // has no selected lines. func (self *Hunk) containsChanges() bool { return nLinesWithKind(self.bodyLines, []PatchLineKind{ADDITION, DELETION}) > 0 } // Returns the number of lines in the hunk, including the header line func (self *Hunk) lineCount() int { return len(self.bodyLines) + 1 } // Returns all lines in the hunk, including the header line func (self *Hunk) allLines() []*PatchLine { lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}} lines = append(lines, self.bodyLines...) return lines } // Returns the header line, including the unified diff header and the context func (self *Hunk) formatHeaderLine() string { return fmt.Sprintf("%s%s", self.formatHeaderStart(), self.headerContext) } // Returns the first part of the header line i.e. the unified diff part (excluding any context) func (self *Hunk) formatHeaderStart() string { newLengthDisplay := "" newLength := self.newLength() // if the new length is 1, it's omitted if newLength != 1 { newLengthDisplay = fmt.Sprintf(",%d", newLength) } return fmt.Sprintf("@@ -%d,%d +%d%s @@", self.oldStart, self.oldLength(), self.newStart, newLengthDisplay) } lazygit-0.50.0+ds1/pkg/commands/patch/parse.go000066400000000000000000000032121500612110400210500ustar00rootroot00000000000000package patch import ( "regexp" "strings" "github.com/jesseduffield/lazygit/pkg/utils" ) var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`) func Parse(patchStr string) *Patch { // ignore trailing newline. lines := strings.Split(strings.TrimSuffix(patchStr, "\n"), "\n") hunks := []*Hunk{} patchHeader := []string{} var currentHunk *Hunk for _, line := range lines { if strings.HasPrefix(line, "@@") { oldStart, newStart, headerContext := headerInfo(line) currentHunk = &Hunk{ oldStart: oldStart, newStart: newStart, headerContext: headerContext, bodyLines: []*PatchLine{}, } hunks = append(hunks, currentHunk) } else if currentHunk != nil { currentHunk.bodyLines = append(currentHunk.bodyLines, newHunkLine(line)) } else { patchHeader = append(patchHeader, line) } } return &Patch{ hunks: hunks, header: patchHeader, } } func headerInfo(header string) (int, int, string) { match := hunkHeaderRegexp.FindStringSubmatch(header) oldStart := utils.MustConvertToInt(match[1]) newStart := utils.MustConvertToInt(match[2]) headerContext := match[3] return oldStart, newStart, headerContext } func newHunkLine(line string) *PatchLine { if line == "" { return &PatchLine{ Kind: CONTEXT, Content: "", } } firstChar := line[:1] kind := parseFirstChar(firstChar) return &PatchLine{ Kind: kind, Content: line, } } func parseFirstChar(firstChar string) PatchLineKind { switch firstChar { case " ": return CONTEXT case "+": return ADDITION case "-": return DELETION case "\\": return NEWLINE_MESSAGE } return CONTEXT } lazygit-0.50.0+ds1/pkg/commands/patch/patch.go000066400000000000000000000112271500612110400210420ustar00rootroot00000000000000package patch import ( "github.com/samber/lo" ) type Patch struct { // header of the patch (split on newlines) e.g. // diff --git a/filename b/filename // index dcd3485..1ba5540 100644 // --- a/filename // +++ b/filename header []string // hunks of the patch hunks []*Hunk } // Returns a new patch with the specified transformation applied (e.g. // only selecting a subset of changes). // Leaves the original patch unchanged. func (self *Patch) Transform(opts TransformOpts) *Patch { return transform(self, opts) } // Returns the patch as a plain string func (self *Patch) FormatPlain() string { return formatPlain(self) } // Returns a range of lines from the patch as a plain string (range is inclusive) func (self *Patch) FormatRangePlain(startIdx int, endIdx int) string { return formatRangePlain(self, startIdx, endIdx) } // Returns the patch as a string with ANSI color codes for displaying in a view func (self *Patch) FormatView(opts FormatViewOpts) string { return formatView(self, opts) } // Returns the lines of the patch func (self *Patch) Lines() []*PatchLine { lines := []*PatchLine{} for _, line := range self.header { lines = append(lines, &PatchLine{Content: line, Kind: PATCH_HEADER}) } for _, hunk := range self.hunks { lines = append(lines, hunk.allLines()...) } return lines } // Returns the patch line index of the first line in the given hunk func (self *Patch) HunkStartIdx(hunkIndex int) int { hunkIndex = lo.Clamp(hunkIndex, 0, len(self.hunks)-1) result := len(self.header) for i := 0; i < hunkIndex; i++ { result += self.hunks[i].lineCount() } return result } // Returns the patch line index of the last line in the given hunk func (self *Patch) HunkEndIdx(hunkIndex int) int { hunkIndex = lo.Clamp(hunkIndex, 0, len(self.hunks)-1) return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1 } func (self *Patch) ContainsChanges() bool { return lo.SomeBy(self.hunks, func(hunk *Hunk) bool { return hunk.containsChanges() }) } // Takes a line index in the patch and returns the line number in the new file. // If the line is a header line, returns 1. // If the line is a hunk header line, returns the first file line number in that hunk. // If the line is out of range below, returns the last file line number in the last hunk. func (self *Patch) LineNumberOfLine(idx int) int { if idx < len(self.header) || len(self.hunks) == 0 { return 1 } hunkIdx := self.HunkContainingLine(idx) // cursor out of range, return last file line number if hunkIdx == -1 { lastHunk := self.hunks[len(self.hunks)-1] return lastHunk.newStart + lastHunk.newLength() - 1 } hunk := self.hunks[hunkIdx] hunkStartIdx := self.HunkStartIdx(hunkIdx) idxInHunk := idx - hunkStartIdx if idxInHunk == 0 { return hunk.newStart } lines := hunk.bodyLines[:idxInHunk-1] offset := nLinesWithKind(lines, []PatchLineKind{ADDITION, CONTEXT}) return hunk.newStart + offset } // Returns hunk index containing the line at the given patch line index func (self *Patch) HunkContainingLine(idx int) int { for hunkIdx, hunk := range self.hunks { hunkStartIdx := self.HunkStartIdx(hunkIdx) if idx >= hunkStartIdx && idx < hunkStartIdx+hunk.lineCount() { return hunkIdx } } return -1 } // Returns the patch line index of the next change (i.e. addition or deletion). func (self *Patch) GetNextChangeIdx(idx int) int { idx = lo.Clamp(idx, 0, self.LineCount()-1) lines := self.Lines() for i, line := range lines[idx:] { if line.isChange() { return i + idx } } // there are no changes from the cursor onwards so we'll instead // return the index of the last change for i := len(lines) - 1; i >= 0; i-- { line := lines[i] if line.isChange() { return i } } // should not be possible return 0 } // Returns the length of the patch in lines func (self *Patch) LineCount() int { count := len(self.header) for _, hunk := range self.hunks { count += hunk.lineCount() } return count } // Returns the number of hunks of the patch func (self *Patch) HunkCount() int { return len(self.hunks) } // Adjust the given line number (one-based) according to the current patch. The // patch is supposed to be a diff of an old file state against the working // directory; the line number is a line number in that old file, and the // function returns the corresponding line number in the working directory file. func (self *Patch) AdjustLineNumber(lineNumber int) int { adjustedLineNumber := lineNumber for _, hunk := range self.hunks { if hunk.oldStart >= lineNumber { break } if hunk.oldStart+hunk.oldLength() > lineNumber { return hunk.newStart } adjustedLineNumber += hunk.newLength() - hunk.oldLength() } return adjustedLineNumber } lazygit-0.50.0+ds1/pkg/commands/patch/patch_builder.go000066400000000000000000000167711500612110400225610ustar00rootroot00000000000000package patch import ( "sort" "strings" "github.com/jesseduffield/generics/maps" "github.com/samber/lo" "github.com/sirupsen/logrus" ) type PatchStatus int const ( // UNSELECTED is for when the commit file has not been added to the patch in any way UNSELECTED PatchStatus = iota // WHOLE is for when you want to add the whole diff of a file to the patch, // including e.g. if it was deleted WHOLE // PART is for when you're only talking about specific lines that have been modified PART ) type fileInfo struct { mode PatchStatus includedLineIndices []int diff string } type ( loadFileDiffFunc func(from string, to string, reverse bool, filename string, plain bool) (string, error) ) // PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility type PatchBuilder struct { // To is the commit hash if we're dealing with files of a commit, or a stash ref for a stash To string From string reverse bool // CanRebase tells us whether we're allowed to modify our commits. CanRebase should be true for commits of the currently checked out branch and false for everything else // TODO: move this out into a proper mode struct in the gui package: it doesn't really belong here CanRebase bool // fileInfoMap starts empty but you add files to it as you go along fileInfoMap map[string]*fileInfo Log *logrus.Entry // loadFileDiff loads the diff of a file, for a given to (typically a commit hash) loadFileDiff loadFileDiffFunc } func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder { return &PatchBuilder{ Log: log, loadFileDiff: loadFileDiff, } } func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { p.To = to p.From = from p.reverse = reverse p.CanRebase = canRebase p.fileInfoMap = map[string]*fileInfo{} } func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string { patch := "" for filename, info := range p.fileInfoMap { if info.mode == UNSELECTED { continue } patch += p.RenderPatchForFile(RenderPatchForFileOpts{ Filename: filename, Plain: true, Reverse: reverse, TurnAddedFilesIntoDiffAgainstEmptyFile: turnAddedFilesIntoDiffAgainstEmptyFile, }) } return patch } func (p *PatchBuilder) addFileWhole(info *fileInfo) { if info.mode != WHOLE { info.mode = WHOLE lineCount := len(strings.Split(info.diff, "\n")) // add every line index // TODO: add tests and then use lo.Range to simplify info.includedLineIndices = make([]int, lineCount) for i := 0; i < lineCount; i++ { info.includedLineIndices[i] = i } } } func (p *PatchBuilder) removeFile(info *fileInfo) { info.mode = UNSELECTED info.includedLineIndices = nil } func (p *PatchBuilder) AddFileWhole(filename string) error { info, err := p.getFileInfo(filename) if err != nil { return err } p.addFileWhole(info) return nil } func (p *PatchBuilder) RemoveFile(filename string) error { info, err := p.getFileInfo(filename) if err != nil { return err } p.removeFile(info) return nil } func getIndicesForRange(first, last int) []int { indices := []int{} for i := first; i <= last; i++ { indices = append(indices, i) } return indices } func (p *PatchBuilder) getFileInfo(filename string) (*fileInfo, error) { info, ok := p.fileInfoMap[filename] if ok { return info, nil } diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, true) if err != nil { return nil, err } info = &fileInfo{ mode: UNSELECTED, diff: diff, } p.fileInfoMap[filename] = info return info, nil } func (p *PatchBuilder) AddFileLineRange(filename string, firstLineIdx, lastLineIdx int) error { info, err := p.getFileInfo(filename) if err != nil { return err } info.mode = PART info.includedLineIndices = lo.Union(info.includedLineIndices, getIndicesForRange(firstLineIdx, lastLineIdx)) return nil } func (p *PatchBuilder) RemoveFileLineRange(filename string, firstLineIdx, lastLineIdx int) error { info, err := p.getFileInfo(filename) if err != nil { return err } info.mode = PART info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, getIndicesForRange(firstLineIdx, lastLineIdx)) if len(info.includedLineIndices) == 0 { p.removeFile(info) } return nil } type RenderPatchForFileOpts struct { Filename string Plain bool Reverse bool TurnAddedFilesIntoDiffAgainstEmptyFile bool } func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string { info, err := p.getFileInfo(opts.Filename) if err != nil { p.Log.Error(err) return "" } if info.mode == UNSELECTED { return "" } if info.mode == WHOLE && opts.Plain { // Use the whole diff (spares us parsing it and then formatting it). // TODO: see if this is actually noticeably faster. // The reverse flag is only for part patches so we're ignoring it here. return info.diff } patch := Parse(info.diff). Transform(TransformOpts{ Reverse: opts.Reverse, TurnAddedFilesIntoDiffAgainstEmptyFile: opts.TurnAddedFilesIntoDiffAgainstEmptyFile, IncludedLineIndices: info.includedLineIndices, }) if opts.Plain { return patch.FormatPlain() } else { return patch.FormatView(FormatViewOpts{}) } } func (p *PatchBuilder) renderEachFilePatch(plain bool) []string { // sort files by name then iterate through and render each patch filenames := maps.Keys(p.fileInfoMap) sort.Strings(filenames) patches := lo.Map(filenames, func(filename string, _ int) string { return p.RenderPatchForFile(RenderPatchForFileOpts{ Filename: filename, Plain: plain, Reverse: false, TurnAddedFilesIntoDiffAgainstEmptyFile: true, }) }) output := lo.Filter(patches, func(patch string, _ int) bool { return patch != "" }) return output } func (p *PatchBuilder) RenderAggregatedPatch(plain bool) string { return strings.Join(p.renderEachFilePatch(plain), "") } func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus { if parent != p.To { return UNSELECTED } info, ok := p.fileInfoMap[filename] if !ok { return UNSELECTED } return info.mode } func (p *PatchBuilder) GetFileIncLineIndices(filename string) ([]int, error) { info, err := p.getFileInfo(filename) if err != nil { return nil, err } return info.includedLineIndices, nil } // clears the patch func (p *PatchBuilder) Reset() { p.To = "" p.fileInfoMap = map[string]*fileInfo{} } func (p *PatchBuilder) Active() bool { return p.To != "" } func (p *PatchBuilder) IsEmpty() bool { for _, fileInfo := range p.fileInfoMap { if fileInfo.mode == WHOLE || (fileInfo.mode == PART && len(fileInfo.includedLineIndices) > 0) { return false } } return true } // if any of these things change we'll need to reset and start a new patch func (p *PatchBuilder) NewPatchRequired(from string, to string, reverse bool) bool { return from != p.From || to != p.To || reverse != p.reverse } func (p *PatchBuilder) AllFilesInPatch() []string { files := make([]string, 0, len(p.fileInfoMap)) for filename := range p.fileInfoMap { files = append(files, filename) } return files } lazygit-0.50.0+ds1/pkg/commands/patch/patch_line.go000066400000000000000000000012261500612110400220470ustar00rootroot00000000000000package patch import "github.com/samber/lo" type PatchLineKind int const ( PATCH_HEADER PatchLineKind = iota HUNK_HEADER ADDITION DELETION CONTEXT NEWLINE_MESSAGE ) type PatchLine struct { Kind PatchLineKind Content string // something like '+ hello' (note the first character is not removed) } func (self *PatchLine) isChange() bool { return self.Kind == ADDITION || self.Kind == DELETION } // Returns the number of lines in the given slice that have one of the given kinds func nLinesWithKind(lines []*PatchLine, kinds []PatchLineKind) int { return lo.CountBy(lines, func(line *PatchLine) bool { return lo.Contains(kinds, line.Kind) }) } lazygit-0.50.0+ds1/pkg/commands/patch/patch_test.go000066400000000000000000000311561500612110400221040ustar00rootroot00000000000000package patch import ( "testing" "github.com/stretchr/testify/assert" ) const simpleDiff = `diff --git a/filename b/filename index dcd3485..1ba5540 100644 --- a/filename +++ b/filename @@ -1,5 +1,5 @@ apple -orange +grape ... ... ... ` const addNewlineToEndOfFile = `diff --git a/filename b/filename index 80a73f1..e48a11c 100644 --- a/filename +++ b/filename @@ -60,4 +60,4 @@ grape ... ... ... -last line \ No newline at end of file +last line ` const removeNewlinefromEndOfFile = `diff --git a/filename b/filename index e48a11c..80a73f1 100644 --- a/filename +++ b/filename @@ -60,4 +60,4 @@ grape ... ... ... -last line +last line \ No newline at end of file ` const twoHunks = `diff --git a/filename b/filename index e48a11c..b2ab81b 100644 --- a/filename +++ b/filename @@ -1,5 +1,5 @@ apple -grape +orange ... ... ... @@ -8,6 +8,8 @@ grape ... ... ... +pear +lemon ... ... ... ` const twoHunksWithMoreAdditionsThanRemovals = `diff --git a/filename b/filename index bac359d75..6e5b89f36 100644 --- a/filename +++ b/filename @@ -1,5 +1,6 @@ apple -grape +orange +kiwi ... ... ... @@ -8,6 +9,8 @@ grape ... ... ... +pear +lemon ... ... ... ` const twoChangesInOneHunk = `diff --git a/filename b/filename index 9320895..6d79956 100644 --- a/filename +++ b/filename @@ -1,5 +1,5 @@ apple -grape +kiwi orange -pear +banana lemon ` const newFile = `diff --git a/newfile b/newfile new file mode 100644 index 0000000..4e680cc --- /dev/null +++ b/newfile @@ -0,0 +1,3 @@ +apple +orange +grape ` const addNewlineToPreviouslyEmptyFile = `diff --git a/newfile b/newfile index e69de29..c6568ea 100644 --- a/newfile +++ b/newfile @@ -0,0 +1 @@ +new line \ No newline at end of file ` const exampleHunk = `@@ -1,5 +1,5 @@ apple -grape +orange ... ... ... ` func TestTransform(t *testing.T) { type scenario struct { testName string filename string diffText string firstLineIndex int lastLineIndex int reverse bool expected string } scenarios := []scenario{ { testName: "nothing selected", filename: "filename", firstLineIndex: -1, lastLineIndex: -1, diffText: simpleDiff, expected: "", }, { testName: "only context selected", filename: "filename", firstLineIndex: 5, lastLineIndex: 5, diffText: simpleDiff, expected: "", }, { testName: "whole range selected", filename: "filename", firstLineIndex: 0, lastLineIndex: 11, diffText: simpleDiff, expected: `--- a/filename +++ b/filename @@ -1,5 +1,5 @@ apple -orange +grape ... ... ... `, }, { testName: "only removal selected", filename: "filename", firstLineIndex: 6, lastLineIndex: 6, diffText: simpleDiff, expected: `--- a/filename +++ b/filename @@ -1,5 +1,4 @@ apple -orange ... ... ... `, }, { testName: "only addition selected", filename: "filename", firstLineIndex: 7, lastLineIndex: 7, diffText: simpleDiff, expected: `--- a/filename +++ b/filename @@ -1,5 +1,6 @@ apple orange +grape ... ... ... `, }, { testName: "range that extends beyond diff bounds", filename: "filename", firstLineIndex: -100, lastLineIndex: 100, diffText: simpleDiff, expected: `--- a/filename +++ b/filename @@ -1,5 +1,5 @@ apple -orange +grape ... ... ... `, }, { testName: "add newline to end of file", filename: "filename", firstLineIndex: -100, lastLineIndex: 100, diffText: addNewlineToEndOfFile, expected: `--- a/filename +++ b/filename @@ -60,4 +60,4 @@ grape ... ... ... -last line \ No newline at end of file +last line `, }, { testName: "add newline to end of file, reversed", filename: "filename", firstLineIndex: -100, lastLineIndex: 100, reverse: true, diffText: addNewlineToEndOfFile, expected: `--- a/filename +++ b/filename @@ -60,4 +60,4 @@ grape ... ... ... -last line \ No newline at end of file +last line `, }, { testName: "remove newline from end of file", filename: "filename", firstLineIndex: -100, lastLineIndex: 100, diffText: removeNewlinefromEndOfFile, expected: `--- a/filename +++ b/filename @@ -60,4 +60,4 @@ grape ... ... ... -last line +last line \ No newline at end of file `, }, { testName: "remove newline from end of file, reversed", filename: "filename", firstLineIndex: -100, lastLineIndex: 100, reverse: true, diffText: removeNewlinefromEndOfFile, expected: `--- a/filename +++ b/filename @@ -60,4 +60,4 @@ grape ... ... ... -last line +last line \ No newline at end of file `, }, { testName: "remove newline from end of file, removal only", filename: "filename", firstLineIndex: 8, lastLineIndex: 8, diffText: removeNewlinefromEndOfFile, expected: `--- a/filename +++ b/filename @@ -60,4 +60,3 @@ grape ... ... ... -last line `, }, { testName: "remove newline from end of file, removal only, reversed", filename: "filename", firstLineIndex: 8, lastLineIndex: 8, reverse: true, diffText: removeNewlinefromEndOfFile, expected: `--- a/filename +++ b/filename @@ -60,5 +60,4 @@ grape ... ... ... -last line last line \ No newline at end of file `, }, { testName: "remove newline from end of file, addition only", filename: "filename", firstLineIndex: 9, lastLineIndex: 9, diffText: removeNewlinefromEndOfFile, expected: `--- a/filename +++ b/filename @@ -60,4 +60,5 @@ grape ... ... ... last line +last line \ No newline at end of file `, }, { testName: "remove newline from end of file, addition only, reversed", filename: "filename", firstLineIndex: 9, lastLineIndex: 9, reverse: true, diffText: removeNewlinefromEndOfFile, expected: `--- a/filename +++ b/filename @@ -60,3 +60,4 @@ grape ... ... ... +last line \ No newline at end of file `, }, { testName: "staging two whole hunks", filename: "filename", firstLineIndex: -100, lastLineIndex: 100, diffText: twoHunks, expected: `--- a/filename +++ b/filename @@ -1,5 +1,5 @@ apple -grape +orange ... ... ... @@ -8,6 +8,8 @@ grape ... ... ... +pear +lemon ... ... ... `, }, { testName: "staging part of both hunks", filename: "filename", firstLineIndex: 7, lastLineIndex: 15, diffText: twoHunks, expected: `--- a/filename +++ b/filename @@ -1,5 +1,6 @@ apple grape +orange ... ... ... @@ -8,6 +9,7 @@ grape ... ... ... +pear ... ... ... `, }, { testName: "adding a new file", filename: "newfile", firstLineIndex: -100, lastLineIndex: 100, diffText: newFile, expected: `--- a/newfile +++ b/newfile @@ -0,0 +1,3 @@ +apple +orange +grape `, }, { testName: "adding part of a new file", filename: "newfile", firstLineIndex: 6, lastLineIndex: 7, diffText: newFile, expected: `--- a/newfile +++ b/newfile @@ -0,0 +1,2 @@ +apple +orange `, }, { testName: "adding a new line to a previously empty file", filename: "newfile", firstLineIndex: -100, lastLineIndex: 100, diffText: addNewlineToPreviouslyEmptyFile, expected: `--- a/newfile +++ b/newfile @@ -0,0 +1 @@ +new line \ No newline at end of file `, }, { testName: "adding a new line to a previously empty file, reversed", filename: "newfile", firstLineIndex: -100, lastLineIndex: 100, diffText: addNewlineToPreviouslyEmptyFile, reverse: true, expected: `--- a/newfile +++ b/newfile @@ -0,0 +1 @@ +new line \ No newline at end of file `, }, { testName: "adding part of a hunk", filename: "filename", firstLineIndex: 6, lastLineIndex: 7, reverse: false, diffText: twoChangesInOneHunk, expected: `--- a/filename +++ b/filename @@ -1,5 +1,5 @@ apple -grape +kiwi orange pear lemon `, }, { testName: "adding part of a hunk, reverse", filename: "filename", firstLineIndex: 6, lastLineIndex: 7, reverse: true, diffText: twoChangesInOneHunk, expected: `--- a/filename +++ b/filename @@ -1,5 +1,5 @@ apple -grape +kiwi orange banana lemon `, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { lineIndices := ExpandRange(s.firstLineIndex, s.lastLineIndex) result := Parse(s.diffText). Transform(TransformOpts{ Reverse: s.reverse, FileNameOverride: s.filename, IncludedLineIndices: lineIndices, }). FormatPlain() assert.Equal(t, s.expected, result) }) } } func TestParseAndFormatPlain(t *testing.T) { scenarios := []struct { testName string patchStr string }{ { testName: "simpleDiff", patchStr: simpleDiff, }, { testName: "addNewlineToEndOfFile", patchStr: addNewlineToEndOfFile, }, { testName: "removeNewlinefromEndOfFile", patchStr: removeNewlinefromEndOfFile, }, { testName: "twoHunks", patchStr: twoHunks, }, { testName: "twoChangesInOneHunk", patchStr: twoChangesInOneHunk, }, { testName: "newFile", patchStr: newFile, }, { testName: "addNewlineToPreviouslyEmptyFile", patchStr: addNewlineToPreviouslyEmptyFile, }, { testName: "exampleHunk", patchStr: exampleHunk, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { // here we parse the patch, then format it, and ensure the result // matches the original patch. Note that unified diffs allow omitting // the new length in a hunk header if the value is 1, and currently we always // omit the new length in such cases. patch := Parse(s.patchStr) result := formatPlain(patch) assert.Equal(t, s.patchStr, result) }) } } func TestLineNumberOfLine(t *testing.T) { type scenario struct { testName string patchStr string indexes []int expecteds []int } scenarios := []scenario{ { testName: "twoHunks", patchStr: twoHunks, // this is really more of a characteristic test than anything. indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 1000}, expecteds: []int{1, 1, 1, 1, 1, 1, 2, 2, 3, 4, 5, 8, 8, 9, 10, 11, 12, 13, 14, 15, 15, 15, 15, 15, 15}, }, { testName: "twoHunksWithMoreAdditionsThanRemovals", patchStr: twoHunksWithMoreAdditionsThanRemovals, indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 1000}, expecteds: []int{1, 1, 1, 1, 1, 1, 2, 2, 3, 4, 5, 6, 9, 9, 10, 11, 12, 13, 14, 15, 16, 16, 16, 16, 16, 16}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { for i, idx := range s.indexes { patch := Parse(s.patchStr) result := patch.LineNumberOfLine(idx) assert.Equal(t, s.expecteds[i], result) } }) } } func TestGetNextStageableLineIndex(t *testing.T) { type scenario struct { testName string patchStr string indexes []int expecteds []int } scenarios := []scenario{ { testName: "twoHunks", patchStr: twoHunks, indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 1000}, expecteds: []int{6, 6, 6, 6, 6, 6, 6, 7, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 16, 16, 16, 16, 16, 16, 16}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { for i, idx := range s.indexes { patch := Parse(s.patchStr) result := patch.GetNextChangeIdx(idx) assert.Equal(t, s.expecteds[i], result) } }) } } func TestAdjustLineNumber(t *testing.T) { type scenario struct { oldLineNumbers []int expectedResults []int } scenarios := []scenario{ { oldLineNumbers: []int{1, 2, 3, 4, 5, 6, 7}, expectedResults: []int{1, 2, 2, 3, 4, 7, 8}, }, } // The following diff was generated from old.txt: // 1 // 2a // 2b // 3 // 4 // 7 // 8 // against new.txt: // 1 // 2 // 3 // 4 // 5 // 6 // 7 // 8 // This test setup makes the test easy to understand, because the resulting // adjusted line numbers are the same as the content of the lines in new.txt. diff := `--- old.txt 2024-12-16 18:04:29 +++ new.txt 2024-12-16 18:04:27 @@ -2,2 +2 @@ -2a -2b +2 @@ -5,0 +5,2 @@ +5 +6 ` patch := Parse(diff) for _, s := range scenarios { t.Run("TestAdjustLineNumber", func(t *testing.T) { for idx, oldLineNumber := range s.oldLineNumbers { result := patch.AdjustLineNumber(oldLineNumber) assert.Equal(t, s.expectedResults[idx], result) } }) } } lazygit-0.50.0+ds1/pkg/commands/patch/transform.go000066400000000000000000000123011500612110400217500ustar00rootroot00000000000000package patch import ( "strings" "github.com/samber/lo" ) type patchTransformer struct { patch *Patch opts TransformOpts } type TransformOpts struct { // Create a patch that will applied in reverse with `git apply --reverse`. // This affects how unselected lines are treated when only parts of a hunk // are selected: usually, for unselected lines we change '-' lines to // context lines and remove '+' lines, but when Reverse is true we need to // turn '+' lines into context lines and remove '-' lines. Reverse bool // If set, we will replace the original header with one referring to this file name. // For staging/unstaging lines we don't want the original header because // it makes git confused e.g. when dealing with deleted/added files // but with building and applying patches the original header gives git // information it needs to cleanly apply patches FileNameOverride string // Custom patches tend to work better when treating new files as diffs // against an empty file. The only case where we need this to be false is // when moving a custom patch to an earlier commit; in that case the patch // command would fail with the error "file does not exist in index" if we // treat it as a diff against an empty file. TurnAddedFilesIntoDiffAgainstEmptyFile bool // The indices of lines that should be included in the patch. IncludedLineIndices []int } func transform(patch *Patch, opts TransformOpts) *Patch { transformer := &patchTransformer{ patch: patch, opts: opts, } return transformer.transform() } // helper function that takes a start and end index and returns a slice of all // indexes inbetween (inclusive) func ExpandRange(start int, end int) []int { expanded := []int{} for i := start; i <= end; i++ { expanded = append(expanded, i) } return expanded } func (self *patchTransformer) transform() *Patch { header := self.transformHeader() hunks := self.transformHunks() return &Patch{ header: header, hunks: hunks, } } func (self *patchTransformer) transformHeader() []string { if self.opts.FileNameOverride != "" { return []string{ "--- a/" + self.opts.FileNameOverride, "+++ b/" + self.opts.FileNameOverride, } } else if self.opts.TurnAddedFilesIntoDiffAgainstEmptyFile { result := make([]string, 0, len(self.patch.header)) for idx, line := range self.patch.header { if strings.HasPrefix(line, "new file mode") { continue } if line == "--- /dev/null" && strings.HasPrefix(self.patch.header[idx+1], "+++ b/") { line = "--- a/" + self.patch.header[idx+1][6:] } result = append(result, line) } return result } else { return self.patch.header } } func (self *patchTransformer) transformHunks() []*Hunk { newHunks := make([]*Hunk, 0, len(self.patch.hunks)) startOffset := 0 var formattedHunk *Hunk for i, hunk := range self.patch.hunks { startOffset, formattedHunk = self.transformHunk( hunk, startOffset, self.patch.HunkStartIdx(i), ) if formattedHunk.containsChanges() { newHunks = append(newHunks, formattedHunk) } } return newHunks } func (self *patchTransformer) transformHunk(hunk *Hunk, startOffset int, firstLineIdx int) (int, *Hunk) { newLines := self.transformHunkLines(hunk, firstLineIdx) newNewStart, newStartOffset := self.transformHunkHeader(newLines, hunk.oldStart, startOffset) newHunk := &Hunk{ bodyLines: newLines, oldStart: hunk.oldStart, newStart: newNewStart, headerContext: hunk.headerContext, } return newStartOffset, newHunk } func (self *patchTransformer) transformHunkLines(hunk *Hunk, firstLineIdx int) []*PatchLine { skippedNewlineMessageIndex := -1 newLines := []*PatchLine{} for i, line := range hunk.bodyLines { lineIdx := i + firstLineIdx + 1 // plus one for header line if line.Content == "" { break } isLineSelected := lo.Contains(self.opts.IncludedLineIndices, lineIdx) if isLineSelected || (line.Kind == NEWLINE_MESSAGE && skippedNewlineMessageIndex != lineIdx) || line.Kind == CONTEXT { newLines = append(newLines, line) continue } if (line.Kind == DELETION && !self.opts.Reverse) || (line.Kind == ADDITION && self.opts.Reverse) { content := " " + line.Content[1:] newLines = append(newLines, &PatchLine{ Kind: CONTEXT, Content: content, }) continue } if line.Kind == ADDITION { // we don't want to include the 'newline at end of file' line if it involves an addition we're not including skippedNewlineMessageIndex = lineIdx + 1 } } return newLines } func (self *patchTransformer) transformHunkHeader(newBodyLines []*PatchLine, oldStart int, startOffset int) (int, int) { oldLength := nLinesWithKind(newBodyLines, []PatchLineKind{CONTEXT, DELETION}) newLength := nLinesWithKind(newBodyLines, []PatchLineKind{CONTEXT, ADDITION}) var newStartOffset int // if the hunk went from zero to positive length, we need to increment the starting point by one // if the hunk went from positive to zero length, we need to decrement the starting point by one if oldLength == 0 { newStartOffset = 1 } else if newLength == 0 { newStartOffset = -1 } else { newStartOffset = 0 } newStart := oldStart + startOffset + newStartOffset newStartOffset = startOffset + newLength - oldLength return newStart, newStartOffset } lazygit-0.50.0+ds1/pkg/commands/testdata/000077500000000000000000000000001500612110400201235ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/testdata/a_dir/000077500000000000000000000000001500612110400212015ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/testdata/a_dir/file000066400000000000000000000000001500612110400220310ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/commands/testdata/a_file000066400000000000000000000000001500612110400212530ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/common/000077500000000000000000000000001500612110400160015ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/common/common.go000066400000000000000000000014261500612110400176230ustar00rootroot00000000000000package common import ( "sync/atomic" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/sirupsen/logrus" "github.com/spf13/afero" ) // Commonly used things wrapped into one struct for convenience when passing it around type Common struct { Log *logrus.Entry Tr *i18n.TranslationSet userConfig atomic.Pointer[config.UserConfig] AppState *config.AppState Debug bool // for interacting with the filesystem. We use afero rather than the default // `os` package for the sake of mocking the filesystem in tests Fs afero.Fs } func (c *Common) UserConfig() *config.UserConfig { return c.userConfig.Load() } func (c *Common) SetUserConfig(userConfig *config.UserConfig) { c.userConfig.Store(userConfig) } lazygit-0.50.0+ds1/pkg/config/000077500000000000000000000000001500612110400157565ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/config/app_config.go000066400000000000000000000375211500612110400204220ustar00rootroot00000000000000package config import ( "fmt" "log" "os" "path/filepath" "reflect" "strings" "time" "github.com/adrg/xdg" "github.com/jesseduffield/lazygit/pkg/utils/yaml_utils" "github.com/samber/lo" "gopkg.in/yaml.v3" ) // AppConfig contains the base configuration fields required for lazygit. type AppConfig struct { debug bool `long:"debug" env:"DEBUG" default:"false"` version string `long:"version" env:"VERSION" default:"unversioned"` buildDate string `long:"build-date" env:"BUILD_DATE"` name string `long:"name" env:"NAME" default:"lazygit"` buildSource string `long:"build-source" env:"BUILD_SOURCE" default:""` userConfig *UserConfig globalUserConfigFiles []*ConfigFile userConfigFiles []*ConfigFile userConfigDir string tempDir string appState *AppState } type AppConfigurer interface { GetDebug() bool // build info GetVersion() string GetName() string GetBuildSource() string GetUserConfig() *UserConfig GetUserConfigPaths() []string GetUserConfigDir() string ReloadUserConfigForRepo(repoConfigFiles []*ConfigFile) error ReloadChangedUserConfigFiles() (error, bool) GetTempDir() string GetAppState() *AppState SaveAppState() error } type ConfigFilePolicy int const ( ConfigFilePolicyCreateIfMissing ConfigFilePolicy = iota ConfigFilePolicyErrorIfMissing ConfigFilePolicySkipIfMissing ) type ConfigFile struct { Path string Policy ConfigFilePolicy modDate time.Time exists bool } // NewAppConfig makes a new app config func NewAppConfig( name string, version, commit, date string, buildSource string, debuggingFlag bool, tempDir string, ) (*AppConfig, error) { configDir, err := findOrCreateConfigDir() if err != nil && !os.IsPermission(err) { return nil, err } var configFiles []*ConfigFile customConfigFiles := os.Getenv("LG_CONFIG_FILE") if customConfigFiles != "" { // Load user defined config files userConfigPaths := strings.Split(customConfigFiles, ",") configFiles = lo.Map(userConfigPaths, func(path string, _ int) *ConfigFile { return &ConfigFile{Path: path, Policy: ConfigFilePolicyErrorIfMissing} }) } else { // Load default config files path := filepath.Join(configDir, ConfigFilename) configFile := &ConfigFile{Path: path, Policy: ConfigFilePolicyCreateIfMissing} configFiles = []*ConfigFile{configFile} } userConfig, err := loadUserConfigWithDefaults(configFiles) if err != nil { return nil, err } appState, err := loadAppState() if err != nil { return nil, err } // Temporary: the defaults for these are set to empty strings in // getDefaultAppState so that we can migrate them from userConfig (which is // now deprecated). Once we remove the user configs, we can remove this code // and set the proper defaults in getDefaultAppState. if appState.GitLogOrder == "" { appState.GitLogOrder = userConfig.Git.Log.Order } if appState.GitLogShowGraph == "" { appState.GitLogShowGraph = userConfig.Git.Log.ShowGraph } appConfig := &AppConfig{ name: name, version: version, buildDate: date, debug: debuggingFlag, buildSource: buildSource, userConfig: userConfig, globalUserConfigFiles: configFiles, userConfigFiles: configFiles, userConfigDir: configDir, tempDir: tempDir, appState: appState, } return appConfig, nil } func ConfigDir() string { _, filePath := findConfigFile("config.yml") return filepath.Dir(filePath) } func findOrCreateConfigDir() (string, error) { folder := ConfigDir() return folder, os.MkdirAll(folder, 0o755) } func loadUserConfigWithDefaults(configFiles []*ConfigFile) (*UserConfig, error) { return loadUserConfig(configFiles, GetDefaultConfig()) } func loadUserConfig(configFiles []*ConfigFile, base *UserConfig) (*UserConfig, error) { for _, configFile := range configFiles { path := configFile.Path statInfo, err := os.Stat(path) if err == nil { configFile.exists = true configFile.modDate = statInfo.ModTime() } else { if !os.IsNotExist(err) { return nil, err } switch configFile.Policy { case ConfigFilePolicyErrorIfMissing: return nil, err case ConfigFilePolicySkipIfMissing: configFile.exists = false continue case ConfigFilePolicyCreateIfMissing: file, err := os.Create(path) if err != nil { if os.IsPermission(err) { // apparently when people have read-only permissions they prefer us to fail silently continue } return nil, err } file.Close() configFile.exists = true statInfo, err := os.Stat(configFile.Path) if err != nil { return nil, err } configFile.modDate = statInfo.ModTime() } } content, err := os.ReadFile(path) if err != nil { return nil, err } content, err = migrateUserConfig(path, content) if err != nil { return nil, err } existingCustomCommands := base.CustomCommands if err := yaml.Unmarshal(content, base); err != nil { return nil, fmt.Errorf("The config at `%s` couldn't be parsed, please inspect it before opening up an issue.\n%w", path, err) } base.CustomCommands = append(base.CustomCommands, existingCustomCommands...) if err := base.Validate(); err != nil { return nil, fmt.Errorf("The config at `%s` has a validation error.\n%w", path, err) } } return base, nil } // Do any backward-compatibility migrations of things that have changed in the // config over time; examples are renaming a key to a better name, moving a key // from one container to another, or changing the type of a key (e.g. from bool // to an enum). func migrateUserConfig(path string, content []byte) ([]byte, error) { changedContent, err := computeMigratedConfig(path, content) if err != nil { return nil, err } // Write config back if changed if string(changedContent) != string(content) { fmt.Println("Provided user config is deprecated but auto-fixable. Attempting to write fixed version back to file...") if err := os.WriteFile(path, changedContent, 0o644); err != nil { return nil, fmt.Errorf("While attempting to write back fixed user config to %s, an error occurred: %s", path, err) } fmt.Printf("Success. New config written to %s\n", path) return changedContent, nil } return content, nil } // A pure function helper for testing purposes func computeMigratedConfig(path string, content []byte) ([]byte, error) { var err error var rootNode yaml.Node err = yaml.Unmarshal(content, &rootNode) if err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) } var originalCopy yaml.Node err = yaml.Unmarshal(content, &originalCopy) if err != nil { return nil, fmt.Errorf("failed to parse YAML, but only the second time!?!? How did that happen: %w", err) } pathsToReplace := []struct { oldPath []string newName string }{ {[]string{"gui", "skipUnstageLineWarning"}, "skipDiscardChangeWarning"}, {[]string{"keybinding", "universal", "executeCustomCommand"}, "executeShellCommand"}, {[]string{"gui", "windowSize"}, "screenMode"}, } for _, pathToReplace := range pathsToReplace { err := yaml_utils.RenameYamlKey(&rootNode, pathToReplace.oldPath, pathToReplace.newName) if err != nil { return nil, fmt.Errorf("Couldn't migrate config file at `%s` for key %s: %s", path, strings.Join(pathToReplace.oldPath, "."), err) } } err = changeNullKeybindingsToDisabled(&rootNode) if err != nil { return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err) } err = changeElementToSequence(&rootNode, []string{"git", "commitPrefix"}) if err != nil { return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err) } err = changeCommitPrefixesMap(&rootNode) if err != nil { return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err) } // Add more migrations here... if !reflect.DeepEqual(rootNode, originalCopy) { newContent, err := yaml_utils.YamlMarshal(&rootNode) if err != nil { return nil, fmt.Errorf("Failed to remarsal!\n %w", err) } return newContent, nil } else { return content, nil } } func changeNullKeybindingsToDisabled(rootNode *yaml.Node) error { return yaml_utils.Walk(rootNode, func(node *yaml.Node, path string) { if strings.HasPrefix(path, "keybinding.") && node.Kind == yaml.ScalarNode && node.Tag == "!!null" { node.Value = "" node.Tag = "!!str" } }) } func changeElementToSequence(rootNode *yaml.Node, path []string) error { return yaml_utils.TransformNode(rootNode, path, func(node *yaml.Node) error { if node.Kind == yaml.MappingNode { nodeContentCopy := node.Content node.Kind = yaml.SequenceNode node.Value = "" node.Tag = "!!seq" node.Content = []*yaml.Node{{ Kind: yaml.MappingNode, Content: nodeContentCopy, }} return nil } return nil }) } func changeCommitPrefixesMap(rootNode *yaml.Node) error { return yaml_utils.TransformNode(rootNode, []string{"git", "commitPrefixes"}, func(prefixesNode *yaml.Node) error { if prefixesNode.Kind == yaml.MappingNode { for _, contentNode := range prefixesNode.Content { if contentNode.Kind == yaml.MappingNode { nodeContentCopy := contentNode.Content contentNode.Kind = yaml.SequenceNode contentNode.Value = "" contentNode.Tag = "!!seq" contentNode.Content = []*yaml.Node{{ Kind: yaml.MappingNode, Content: nodeContentCopy, }} } } } return nil }) } func (c *AppConfig) GetDebug() bool { return c.debug } func (c *AppConfig) GetVersion() string { return c.version } func (c *AppConfig) GetName() string { return c.name } // GetBuildSource returns the source of the build. For builds from goreleaser // this will be binaryBuild func (c *AppConfig) GetBuildSource() string { return c.buildSource } // GetUserConfig returns the user config func (c *AppConfig) GetUserConfig() *UserConfig { return c.userConfig } // GetAppState returns the app state func (c *AppConfig) GetAppState() *AppState { return c.appState } func (c *AppConfig) GetUserConfigPaths() []string { return lo.FilterMap(c.userConfigFiles, func(f *ConfigFile, _ int) (string, bool) { return f.Path, f.exists }) } func (c *AppConfig) GetUserConfigDir() string { return c.userConfigDir } func (c *AppConfig) ReloadUserConfigForRepo(repoConfigFiles []*ConfigFile) error { configFiles := append(c.globalUserConfigFiles, repoConfigFiles...) userConfig, err := loadUserConfigWithDefaults(configFiles) if err != nil { return err } c.userConfig = userConfig c.userConfigFiles = configFiles return nil } func (c *AppConfig) ReloadChangedUserConfigFiles() (error, bool) { fileHasChanged := func(f *ConfigFile) bool { info, err := os.Stat(f.Path) if err != nil && !os.IsNotExist(err) { // If we can't stat the file, assume it hasn't changed return false } exists := err == nil return exists != f.exists || (exists && info.ModTime() != f.modDate) } if lo.NoneBy(c.userConfigFiles, fileHasChanged) { return nil, false } userConfig, err := loadUserConfigWithDefaults(c.userConfigFiles) if err != nil { return err, false } c.userConfig = userConfig return nil, true } func (c *AppConfig) GetTempDir() string { return c.tempDir } // findConfigFile looks for a possibly existing config file. // This function does NOT create any folders or files. func findConfigFile(filename string) (exists bool, path string) { if envConfigDir := os.Getenv("CONFIG_DIR"); envConfigDir != "" { return true, filepath.Join(envConfigDir, filename) } // look for jesseduffield/lazygit/filename in XDG_CONFIG_HOME and XDG_CONFIG_DIRS legacyConfigPath, err := xdg.SearchConfigFile(filepath.Join("jesseduffield", "lazygit", filename)) if err == nil { return true, legacyConfigPath } // look for lazygit/filename in XDG_CONFIG_HOME and XDG_CONFIG_DIRS configFilepath, err := xdg.SearchConfigFile(filepath.Join("lazygit", filename)) if err == nil { return true, configFilepath } return false, filepath.Join(xdg.ConfigHome, "lazygit", filename) } var ConfigFilename = "config.yml" // stateFilePath looks for a possibly existing state file. // if none exist, the default path is returned and all parent directories are created. func stateFilePath(filename string) (string, error) { exists, legacyStateFile := findConfigFile(filename) if exists { return legacyStateFile, nil } // looks for XDG_STATE_HOME/lazygit/filename return xdg.StateFile(filepath.Join("lazygit", filename)) } // SaveAppState marshalls the AppState struct and writes it to the disk func (c *AppConfig) SaveAppState() error { marshalledAppState, err := yaml.Marshal(c.appState) if err != nil { return err } filepath, err := stateFilePath(stateFileName) if err != nil { return err } err = os.WriteFile(filepath, marshalledAppState, 0o644) if err != nil && os.IsPermission(err) { // apparently when people have read-only permissions they prefer us to fail silently return nil } return err } var stateFileName = "state.yml" // loadAppState loads recorded AppState from file func loadAppState() (*AppState, error) { appState := getDefaultAppState() filepath, err := stateFilePath(stateFileName) if err != nil { if os.IsPermission(err) { // apparently when people have read-only permissions they prefer us to fail silently return appState, nil } return nil, err } appStateBytes, err := os.ReadFile(filepath) if err != nil && !os.IsNotExist(err) { return nil, err } if len(appStateBytes) == 0 { return appState, nil } err = yaml.Unmarshal(appStateBytes, appState) if err != nil { return nil, err } return appState, nil } // SaveGlobalUserConfig saves the UserConfig back to disk. This is only used in // integration tests, so we are a bit sloppy with error handling. func (c *AppConfig) SaveGlobalUserConfig() { if len(c.globalUserConfigFiles) != 1 { panic("expected exactly one global user config file") } yamlContent, err := yaml.Marshal(c.userConfig) if err != nil { log.Fatalf("error marshalling user config: %v", err) } err = os.WriteFile(c.globalUserConfigFiles[0].Path, yamlContent, 0o644) if err != nil { log.Fatalf("error saving user config: %v", err) } } // AppState stores data between runs of the app like when the last update check // was performed and which other repos have been checked out type AppState struct { LastUpdateCheck int64 RecentRepos []string StartupPopupVersion int LastVersion string // this is the last version the user was using, for the purpose of showing release notes // these are for shell commands typed in directly, not for custom commands in the lazygit config. // For backwards compatibility we keep the old name in yaml files. ShellCommandsHistory []string `yaml:"customcommandshistory"` HideCommandLog bool IgnoreWhitespaceInDiffView bool DiffContextSize uint64 RenameSimilarityThreshold int LocalBranchSortOrder string RemoteBranchSortOrder string // One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default' // 'topo-order' makes it easier to read the git log graph, but commits may not // appear chronologically. See https://git-scm.com/docs/ GitLogOrder string // This determines whether the git graph is rendered in the commits panel // One of 'always' | 'never' | 'when-maximised' GitLogShowGraph string } func getDefaultAppState() *AppState { return &AppState{ LastUpdateCheck: 0, RecentRepos: []string{}, StartupPopupVersion: 0, LastVersion: "", DiffContextSize: 3, RenameSimilarityThreshold: 50, LocalBranchSortOrder: "recency", RemoteBranchSortOrder: "alphabetical", GitLogOrder: "", // should be "topo-order" eventually GitLogShowGraph: "", // should be "always" eventually } } func LogPath() (string, error) { if os.Getenv("LAZYGIT_LOG_PATH") != "" { return os.Getenv("LAZYGIT_LOG_PATH"), nil } return stateFilePath("development.log") } lazygit-0.50.0+ds1/pkg/config/app_config_test.go000066400000000000000000000507701500612110400214620ustar00rootroot00000000000000package config import ( "testing" "github.com/stretchr/testify/assert" ) func TestCommitPrefixMigrations(t *testing.T) { scenarios := []struct { name string input string expected string }{ { name: "Empty String", input: "", expected: "", }, { name: "Single CommitPrefix Rename", input: `git: commitPrefix: pattern: "^\\w+-\\w+.*" replace: '[JIRA $0] ' `, expected: `git: commitPrefix: - pattern: "^\\w+-\\w+.*" replace: '[JIRA $0] ' `, }, { name: "Complicated CommitPrefixes Rename", input: `git: commitPrefixes: foo: pattern: "^\\w+-\\w+.*" replace: '[OTHER $0] ' CrazyName!@#$^*&)_-)[[}{f{[]: pattern: "^foo.bar*" replace: '[FUN $0] ' `, expected: `git: commitPrefixes: foo: - pattern: "^\\w+-\\w+.*" replace: '[OTHER $0] ' CrazyName!@#$^*&)_-)[[}{f{[]: - pattern: "^foo.bar*" replace: '[FUN $0] ' `, }, { name: "Incomplete Configuration", input: "git:", expected: "git:", }, { // This test intentionally uses non-standard indentation to test that the migration // does not change the input. name: "No changes made when already migrated", input: ` git: commitPrefix: - pattern: "Hello World" replace: "Goodbye" commitPrefixes: foo: - pattern: "^\\w+-\\w+.*" replace: '[JIRA $0] '`, expected: ` git: commitPrefix: - pattern: "Hello World" replace: "Goodbye" commitPrefixes: foo: - pattern: "^\\w+-\\w+.*" replace: '[JIRA $0] '`, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { actual, err := computeMigratedConfig("path doesn't matter", []byte(s.input)) if err != nil { t.Error(err) } assert.Equal(t, s.expected, string(actual)) }) } } var largeConfiguration = []byte(` # Config relating to the Lazygit UI gui: # The number of lines you scroll by when scrolling the main window scrollHeight: 2 # If true, allow scrolling past the bottom of the content in the main window scrollPastBottom: true # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#scroll-off-margin scrollOffMargin: 2 # One of: 'margin' (default) | 'jump' scrollOffBehavior: margin # The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs. # Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command. tabWidth: 4 # If true, capture mouse events. # When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS. mouseEvents: true # If true, do not show a warning when discarding changes in the staging view. skipDiscardChangeWarning: false # If true, do not show warning when applying/popping the stash skipStashWarning: false # If true, do not show a warning when attempting to commit without any staged files; instead stage all unstaged files. skipNoStagedFilesWarning: false # If true, do not show a warning when rewording a commit via an external editor skipRewordInEditorWarning: false # Fraction of the total screen width to use for the left side section. You may want to pick a small number (e.g. 0.2) if you're using a narrow screen, so that you can see more of the main section. # Number from 0 to 1.0. sidePanelWidth: 0.3333 # If true, increase the height of the focused side window; creating an accordion effect. expandFocusedSidePanel: false # The weight of the expanded side panel, relative to the other panels. 2 means # twice as tall as the other panels. Only relevant if expandFocusedSidePanel is true. expandedSidePanelWeight: 2 # Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split. # Options are: # - 'horizontal': split the window horizontally # - 'vertical': split the window vertically # - 'flexible': (default) split the window horizontally if the window is wide enough, otherwise split vertically mainPanelSplitMode: flexible # How the window is split when in half screen mode (i.e. after hitting '+' once). # Possible values: # - 'left': split the window horizontally (side panel on the left, main view on the right) # - 'top': split the window vertically (side panel on top, main view below) enlargedSideViewLocation: left # If true, wrap lines in the staging view to the width of the view. This # makes it much easier to work with diffs that have long lines, e.g. # paragraphs of markdown text. wrapLinesInStagingView: true # One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' language: auto # Format used when displaying time e.g. commit time. # Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format timeFormat: 02 Jan 06 # Format used when displaying time if the time is less than 24 hours ago. # Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format shortTimeFormat: 3:04PM # Config relating to colors and styles. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes theme: # Border color of focused window activeBorderColor: - green - bold # Border color of non-focused windows inactiveBorderColor: - default # Border color of focused window when searching in that window searchingActiveBorderColor: - cyan - bold # Color of keybindings help text in the bottom line optionsTextColor: - blue # Background color of selected line. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line selectedLineBgColor: - blue # Background color of selected line when view doesn't have focus. inactiveViewSelectedLineBgColor: - bold # Foreground color of copied commit cherryPickedCommitFgColor: - blue # Background color of copied commit cherryPickedCommitBgColor: - cyan # Foreground color of marked base commit (for rebase) markedBaseCommitFgColor: - blue # Background color of marked base commit (for rebase) markedBaseCommitBgColor: - yellow # Color for file with unstaged changes unstagedChangesColor: - red # Default text color defaultFgColor: - default # Config relating to the commit length indicator commitLength: # If true, show an indicator of commit message length show: true # If true, show the '5 of 20' footer at the bottom of list views showListFooter: true # If true, display the files in the file views as a tree. If false, display the files as a flat list. # This can be toggled from within Lazygit with the '' key, but that will not change the default. showFileTree: true # If true, show the number of lines changed per file in the Files view showNumstatInFilesView: false # If true, show a random tip in the command log when Lazygit starts showRandomTip: true # If true, show the command log showCommandLog: true # If true, show the bottom line that contains keybinding info and useful buttons. If false, this line will be hidden except to display a loader for an in-progress action. showBottomLine: true # If true, show jump-to-window keybindings in window titles. showPanelJumps: true # Deprecated: use nerdFontsVersion instead showIcons: false # Nerd fonts version to use. # One of: '2' | '3' | empty string (default) # If empty, do not show icons. nerdFontsVersion: "" # If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty. showFileIcons: true # Length of author name in (non-expanded) commits view. 2 means show initials only. commitAuthorShortLength: 2 # Length of author name in expanded commits view. 2 means show initials only. commitAuthorLongLength: 17 # Length of commit hash in commits view. 0 shows '*' if NF icons aren't on. commitHashLength: 8 # If true, show commit hashes alongside branch names in the branches view. showBranchCommitHash: false # Whether to show the divergence from the base branch in the branches view. # One of: 'none' | 'onlyArrow' | 'arrowAndNumber' showDivergenceFromBaseBranch: none # Height of the command log view commandLogSize: 8 # Whether to split the main window when viewing file changes. # One of: 'auto' | 'always' # If 'auto', only split the main window when a file has both staged and unstaged changes splitDiff: auto # Default size for focused window. Can be changed from within Lazygit with '+' and '_' (but this won't change the default). # One of: 'normal' (default) | 'half' | 'full' screenMode: normal # Window border style. # One of 'rounded' (default) | 'single' | 'double' | 'hidden' border: rounded # If true, show a seriously epic explosion animation when nuking the working tree. animateExplosion: true # Whether to stack UI components on top of each other. # One of 'auto' (default) | 'always' | 'never' portraitMode: auto # How things are filtered when typing '/'. # One of 'substring' (default) | 'fuzzy' filterMode: substring # Config relating to the spinner. spinner: # The frames of the spinner animation. frames: - '|' - / - '-' - \ # The "speed" of the spinner in milliseconds. rate: 50 # Status panel view. # One of 'dashboard' (default) | 'allBranchesLog' statusPanelView: dashboard # If true, jump to the Files panel after popping a stash switchToFilesAfterStashPop: true # If true, jump to the Files panel after applying a stash switchToFilesAfterStashApply: true # If true, when using the panel jump keys (default 1 through 5) and target panel is already active, go to next tab instead switchTabsWithPanelJumpKeys: false # Config relating to git git: # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md paging: # Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never' colorArg: always # e.g. # diff-so-fancy # delta --dark --paging=never # ydiff -p cat -s --wrap --width={{columnWidth}} pager: "" useConfig: false # e.g. 'difft --color=always' externalDiffCommand: "" # Config relating to committing commit: # If true, pass '--signoff' flag when committing signOff: false # Automatic WYSIWYG wrapping of the commit message as you type autoWrapCommitMessage: true # If autoWrapCommitMessage is true, the width to wrap to autoWrapWidth: 72 # Config relating to merging merging: # If true, run merges in a subprocess so that if a commit message is required, Lazygit will not hang # Only applicable to unix users. manualCommit: false # Extra args passed to , e.g. --no-ff args: "" # The commit message to use for a squash merge commit. Can contain "{{selectedRef}}" and "{{currentBranch}}" placeholders. squashMergeMessage: Squash merge {{selectedRef}} into {{currentBranch}} # list of branches that are considered 'main' branches, used when displaying commits mainBranches: - master - main # Prefix to use when skipping hooks. E.g. if set to 'WIP', then pre-commit hooks will be skipped when the commit message starts with 'WIP' skipHookPrefix: WIP # If true, periodically fetch from remote autoFetch: true # If true, periodically refresh files and submodules autoRefresh: true # If true, pass the --all arg to git fetch fetchAll: true # If true, lazygit will automatically stage files that used to have merge # conflicts but no longer do; and it will also ask you if you want to # continue a merge or rebase if you've resolved all conflicts. If false, it # won't do either of these things. autoStageResolvedConflicts: true # Command used when displaying the current branch git log in the main window branchLogCmd: git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} -- # Command used to display git log of all branches in the main window. # Deprecated: Use allBranchesLogCmds instead. allBranchesLogCmd: git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium # If true, do not spawn a separate process when using GPG overrideGpg: false # If true, do not allow force pushes disableForcePushing: false # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-branch-name-prefix branchPrefix: "" # If true, parse emoji strings in commit messages e.g. render :rocket: as 🚀 # (This should really be under 'gui', not 'git') parseEmoji: false # Config for showing the log in the commits view log: # One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default' # 'topo-order' makes it easier to read the git log graph, but commits may not # appear chronologically. See https://git-scm.com/docs/ # # Deprecated: Configure this with Log menu -> Commit sort order ( in the commits window by default). order: topo-order # This determines whether the git graph is rendered in the commits panel # One of 'always' | 'never' | 'when-maximised' # # Deprecated: Configure this with Log menu -> Show git graph ( in the commits window by default). showGraph: always # displays the whole git graph by default in the commits view (equivalent to passing the --all argument to git log) showWholeGraph: false # When copying commit hashes to the clipboard, truncate them to this # length. Set to 40 to disable truncation. truncateCopiedCommitHashesTo: 12 # Periodic update checks update: # One of: 'prompt' (default) | 'background' | 'never' method: prompt # Period in days between update checks days: 14 # Background refreshes refresher: # File/submodule refresh interval in seconds. # Auto-refresh can be disabled via option 'git.autoRefresh'. refreshInterval: 10 # Re-fetch interval in seconds. # Auto-fetch can be disabled via option 'git.autoFetch'. fetchInterval: 60 # If true, show a confirmation popup before quitting Lazygit confirmOnQuit: false # If true, exit Lazygit when the user presses escape in a context where there is nothing to cancel/close quitOnTopLevelReturn: false # Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc os: # Command for editing a file. Should contain "{{filename}}". edit: "" # Command for editing a file at a given line number. Should contain # "{{filename}}", and may optionally contain "{{line}}". editAtLine: "" # Same as EditAtLine, except that the command needs to wait until the # window is closed. editAtLineAndWait: "" # Whether lazygit suspends until an edit process returns editInTerminal: false # For opening a directory in an editor openDirInEditor: "" # A built-in preset that sets all of the above settings. Supported presets # are defined in the getPreset function in editor_presets.go. editPreset: "" # Command for opening a file, as if the file is double-clicked. Should # contain "{{filename}}", but doesn't support "{{line}}". open: "" # Command for opening a link. Should contain "{{link}}". openLink: "" # EditCommand is the command for editing a file. # Deprecated: use Edit instead. Note that semantics are different: # EditCommand is just the command itself, whereas Edit contains a # "{{filename}}" variable. editCommand: "" # EditCommandTemplate is the command template for editing a file # Deprecated: use EditAtLine instead. editCommandTemplate: "" # OpenCommand is the command for opening a file # Deprecated: use Open instead. openCommand: "" # OpenLinkCommand is the command for opening a link # Deprecated: use OpenLink instead. openLinkCommand: "" # CopyToClipboardCmd is the command for copying to clipboard. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard copyToClipboardCmd: "" # ReadFromClipboardCmd is the command for reading the clipboard. # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard readFromClipboardCmd: "" # If true, don't display introductory popups upon opening Lazygit. disableStartupPopups: false # What to do when opening Lazygit outside of a git repo. # - 'prompt': (default) ask whether to initialize a new repo or open in the most recent repo # - 'create': initialize a new repo # - 'skip': open most recent repo # - 'quit': exit Lazygit notARepository: prompt # If true, display a confirmation when subprocess terminates. This allows you to view the output of the subprocess before returning to Lazygit. promptToReturnFromSubprocess: true # Keybindings keybinding: universal: quit: q quit-alt1: return: quitWithoutChangingDirectory: Q togglePanel: prevItem: nextItem: prevItem-alt: k nextItem-alt: j prevPage: ',' nextPage: . scrollLeft: H scrollRight: L gotoTop: < gotoBottom: '>' toggleRangeSelect: v rangeSelectDown: rangeSelectUp: prevBlock: nextBlock: prevBlock-alt: h nextBlock-alt: l nextBlock-alt2: prevBlock-alt2: jumpToBlock: - "1" - "2" - "3" - "4" - "5" nextMatch: "n" prevMatch: "N" startSearch: / optionMenu: optionMenu-alt1: '?' select: goInto: confirm: confirmInEditor: remove: d new: "n" edit: e openFile: o scrollUpMain: scrollDownMain: scrollUpMain-alt1: K scrollDownMain-alt1: J scrollUpMain-alt2: scrollDownMain-alt2: executeShellCommand: ':' createRebaseOptionsMenu: m # 'Files' appended for legacy reasons pushFiles: P # 'Files' appended for legacy reasons pullFiles: p refresh: R createPatchOptionsMenu: nextTab: ']' prevTab: '[' nextScreenMode: + prevScreenMode: _ undo: z redo: filteringMenu: diffingMenu: W diffingMenu-alt: copyToClipboard: openRecentRepos: submitEditorText: extrasMenu: '@' toggleWhitespaceInDiffView: increaseContextInDiffView: '}' decreaseContextInDiffView: '{' increaseRenameSimilarityThreshold: ) decreaseRenameSimilarityThreshold: ( openDiffTool: status: checkForUpdate: u recentRepos: allBranchesLogGraph: a files: commitChanges: c commitChangesWithoutHook: w amendLastCommit: A commitChangesWithEditor: C findBaseCommitForFixup: confirmDiscard: x ignoreFile: i refreshFiles: r stashAllChanges: s viewStashOptions: S toggleStagedAll: a viewResetOptions: D fetch: f openMergeTool: M openStatusFilter: copyFileInfoToClipboard: "y" collapseAll: '-' expandAll: = branches: createPullRequest: o viewPullRequestOptions: O copyPullRequestURL: checkoutBranchByName: c forceCheckoutBranch: F rebaseBranch: r renameBranch: R mergeIntoCurrentBranch: M viewGitFlowOptions: i fastForward: f createTag: T pushTag: P setUpstream: u fetchRemote: f sortOrder: s worktrees: viewWorktreeOptions: w commits: squashDown: s renameCommit: r renameCommitWithEditor: R viewResetOptions: g markCommitAsFixup: f createFixupCommit: F squashAboveCommits: S moveDownCommit: moveUpCommit: amendToCommit: A resetCommitAuthor: a pickCommit: p revertCommit: t cherryPickCopy: C pasteCommits: V markCommitAsBaseForRebase: B tagCommit: T checkoutCommit: resetCherryPick: copyCommitAttributeToClipboard: "y" openLogMenu: openInBrowser: o viewBisectOptions: b startInteractiveRebase: i amendAttribute: resetAuthor: a setAuthor: A addCoAuthor: c stash: popStash: g renameStash: r commitFiles: checkoutCommitFile: c main: toggleSelectHunk: a pickBothHunks: b editSelectHunk: E submodules: init: i update: u bulkMenu: b commitMessage: commitMenu: `) func BenchmarkMigrationOnLargeConfiguration(b *testing.B) { for b.Loop() { _, _ = computeMigratedConfig("path doesn't matter", largeConfiguration) } } lazygit-0.50.0+ds1/pkg/config/config_default_platform.go000066400000000000000000000004131500612110400231600ustar00rootroot00000000000000//go:build !windows && !linux // +build !windows,!linux package config // GetPlatformDefaultConfig gets the defaults for the platform func GetPlatformDefaultConfig() OSConfig { return OSConfig{ Open: "open -- {{filename}}", OpenLink: "open {{link}}", } } lazygit-0.50.0+ds1/pkg/config/config_linux.go000066400000000000000000000015041500612110400207710ustar00rootroot00000000000000package config import ( "os" "strings" ) func isWSL() bool { data, err := os.ReadFile("/proc/sys/kernel/osrelease") return err == nil && strings.Contains(string(data), "microsoft") } func isContainer() bool { data, err := os.ReadFile("/proc/1/cgroup") return err == nil && (strings.Contains(string(data), "docker") || strings.Contains(string(data), "/lxc/") || os.Getenv("CONTAINER") != "") } // GetPlatformDefaultConfig gets the defaults for the platform func GetPlatformDefaultConfig() OSConfig { if isWSL() && !isContainer() { return OSConfig{ Open: `powershell.exe start explorer.exe "$(wslpath -w {{filename}})" >/dev/null`, OpenLink: `powershell.exe start '{{link}}' >/dev/null`, } } return OSConfig{ Open: `xdg-open {{filename}} >/dev/null`, OpenLink: `xdg-open {{link}} >/dev/null`, } } lazygit-0.50.0+ds1/pkg/config/config_windows.go000066400000000000000000000003271500612110400213260ustar00rootroot00000000000000package config // GetPlatformDefaultConfig gets the defaults for the platform func GetPlatformDefaultConfig() OSConfig { return OSConfig{ Open: `start "" {{filename}}`, OpenLink: `start "" {{link}}`, } } lazygit-0.50.0+ds1/pkg/config/dummies.go000066400000000000000000000006001500612110400177440ustar00rootroot00000000000000package config import ( "gopkg.in/yaml.v3" ) // NewDummyAppConfig creates a new dummy AppConfig for testing func NewDummyAppConfig() *AppConfig { appConfig := &AppConfig{ name: "lazygit", version: "unversioned", debug: false, userConfig: GetDefaultConfig(), appState: &AppState{}, } _ = yaml.Unmarshal([]byte{}, appConfig.appState) return appConfig } lazygit-0.50.0+ds1/pkg/config/editor_presets.go000066400000000000000000000174421500612110400213500ustar00rootroot00000000000000package config import ( "os" "strings" ) func GetEditTemplate(shell string, osConfig *OSConfig, guessDefaultEditor func() string) (string, bool) { preset := getPreset(shell, osConfig, guessDefaultEditor) template := osConfig.Edit if template == "" { template = preset.editTemplate } return template, getEditInTerminal(osConfig, preset) } func GetEditAtLineTemplate(shell string, osConfig *OSConfig, guessDefaultEditor func() string) (string, bool) { preset := getPreset(shell, osConfig, guessDefaultEditor) template := osConfig.EditAtLine if template == "" { template = preset.editAtLineTemplate } return template, getEditInTerminal(osConfig, preset) } func GetEditAtLineAndWaitTemplate(shell string, osConfig *OSConfig, guessDefaultEditor func() string) string { preset := getPreset(shell, osConfig, guessDefaultEditor) template := osConfig.EditAtLineAndWait if template == "" { template = preset.editAtLineAndWaitTemplate } return template } func GetOpenDirInEditorTemplate(shell string, osConfig *OSConfig, guessDefaultEditor func() string) (string, bool) { preset := getPreset(shell, osConfig, guessDefaultEditor) template := osConfig.OpenDirInEditor if template == "" { template = preset.openDirInEditorTemplate } return template, getEditInTerminal(osConfig, preset) } type editPreset struct { editTemplate string editAtLineTemplate string editAtLineAndWaitTemplate string openDirInEditorTemplate string suspend func() bool } func returnBool(a bool) func() bool { return (func() bool { return a }) } // IF YOU ADD A PRESET TO THIS FUNCTION YOU MUST UPDATE THE `Supported presets` SECTION OF docs/Config.md func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset { var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string // By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012. if (strings.HasSuffix(shell, "fish")) || (os.Getenv("FISH_VERSION") != "") { nvimRemoteEditTemplate = `begin; if test -z "$NVIM"; nvim -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; end; end` nvimRemoteEditAtLineTemplate = `begin; if test -z "$NVIM"; nvim +{{line}} -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; nvim --server "$NVIM" --remote-send ":{{line}}"; end; end` nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end` } else { nvimRemoteEditTemplate = `[ -z "$NVIM" ] && (nvim -- {{filename}}) || (nvim --server "$NVIM" --remote-send "q" && nvim --server "$NVIM" --remote-tab {{filename}})` nvimRemoteEditAtLineTemplate = `[ -z "$NVIM" ] && (nvim +{{line}} -- {{filename}}) || (nvim --server "$NVIM" --remote-send "q" && nvim --server "$NVIM" --remote-tab {{filename}} && nvim --server "$NVIM" --remote-send ":{{line}}")` nvimRemoteOpenDirInEditorTemplate = `[ -z "$NVIM" ] && (nvim -- {{dir}}) || (nvim --server "$NVIM" --remote-send "q" && nvim --server "$NVIM" --remote-tab {{dir}})` } presets := map[string]*editPreset{ "vi": standardTerminalEditorPreset("vi"), "vim": standardTerminalEditorPreset("vim"), "nvim": standardTerminalEditorPreset("nvim"), "nvim-remote": { editTemplate: nvimRemoteEditTemplate, editAtLineTemplate: nvimRemoteEditAtLineTemplate, // No remote-wait support yet. See https://github.com/neovim/neovim/pull/17856 editAtLineAndWaitTemplate: `nvim +{{line}} {{filename}}`, openDirInEditorTemplate: nvimRemoteOpenDirInEditorTemplate, suspend: func() bool { _, ok := os.LookupEnv("NVIM") return !ok }, }, "lvim": standardTerminalEditorPreset("lvim"), "emacs": standardTerminalEditorPreset("emacs"), "micro": { editTemplate: "micro {{filename}}", editAtLineTemplate: "micro +{{line}} {{filename}}", editAtLineAndWaitTemplate: "micro +{{line}} {{filename}}", openDirInEditorTemplate: "micro {{dir}}", suspend: returnBool(true), }, "nano": standardTerminalEditorPreset("nano"), "kakoune": standardTerminalEditorPreset("kak"), "helix": { editTemplate: "helix -- {{filename}}", editAtLineTemplate: "helix -- {{filename}}:{{line}}", editAtLineAndWaitTemplate: "helix -- {{filename}}:{{line}}", openDirInEditorTemplate: "helix -- {{dir}}", suspend: returnBool(true), }, "helix (hx)": { editTemplate: "hx -- {{filename}}", editAtLineTemplate: "hx -- {{filename}}:{{line}}", editAtLineAndWaitTemplate: "hx -- {{filename}}:{{line}}", openDirInEditorTemplate: "hx -- {{dir}}", suspend: returnBool(true), }, "vscode": { editTemplate: "code --reuse-window -- {{filename}}", editAtLineTemplate: "code --reuse-window --goto -- {{filename}}:{{line}}", editAtLineAndWaitTemplate: "code --reuse-window --goto --wait -- {{filename}}:{{line}}", openDirInEditorTemplate: "code -- {{dir}}", suspend: returnBool(false), }, "sublime": { editTemplate: "subl -- {{filename}}", editAtLineTemplate: "subl -- {{filename}}:{{line}}", editAtLineAndWaitTemplate: "subl --wait -- {{filename}}:{{line}}", openDirInEditorTemplate: "subl -- {{dir}}", suspend: returnBool(false), }, "bbedit": { editTemplate: "bbedit -- {{filename}}", editAtLineTemplate: "bbedit +{{line}} -- {{filename}}", editAtLineAndWaitTemplate: "bbedit +{{line}} --wait -- {{filename}}", openDirInEditorTemplate: "bbedit -- {{dir}}", suspend: returnBool(false), }, "xcode": { editTemplate: "xed -- {{filename}}", editAtLineTemplate: "xed --line {{line}} -- {{filename}}", editAtLineAndWaitTemplate: "xed --line {{line}} --wait -- {{filename}}", openDirInEditorTemplate: "xed -- {{dir}}", suspend: returnBool(false), }, "zed": { editTemplate: "zed -- {{filename}}", editAtLineTemplate: "zed -- {{filename}}:{{line}}", editAtLineAndWaitTemplate: "zed --wait -- {{filename}}:{{line}}", openDirInEditorTemplate: "zed -- {{dir}}", suspend: returnBool(false), }, "acme": { editTemplate: "B {{filename}}", editAtLineTemplate: "B {{filename}}:{{line}}", editAtLineAndWaitTemplate: "E {{filename}}:{{line}}", openDirInEditorTemplate: "B {{dir}}", suspend: returnBool(false), }, } // Some of our presets have a different name than the editor they are using. editorToPreset := map[string]string{ "kak": "kakoune", "helix": "helix", "hx": "helix (hx)", "code": "vscode", "subl": "sublime", "xed": "xcode", } presetName := osConfig.EditPreset if presetName == "" { defaultEditor := guessDefaultEditor() if presets[defaultEditor] != nil { presetName = defaultEditor } else if p := editorToPreset[defaultEditor]; p != "" { presetName = p } } if presetName == "" || presets[presetName] == nil { presetName = "vim" } return presets[presetName] } func standardTerminalEditorPreset(editor string) *editPreset { return &editPreset{ editTemplate: editor + " -- {{filename}}", editAtLineTemplate: editor + " +{{line}} -- {{filename}}", editAtLineAndWaitTemplate: editor + " +{{line}} -- {{filename}}", openDirInEditorTemplate: editor + " -- {{dir}}", suspend: returnBool(true), } } func getEditInTerminal(osConfig *OSConfig, preset *editPreset) bool { if osConfig.SuspendOnEdit != nil { return *osConfig.SuspendOnEdit } return preset.suspend() } lazygit-0.50.0+ds1/pkg/config/editor_presets_test.go000066400000000000000000000067131500612110400224060ustar00rootroot00000000000000package config import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetEditTemplate(t *testing.T) { trueVal := true scenarios := []struct { name string osConfig *OSConfig guessDefaultEditor func() string expectedEditTemplate string expectedEditAtLineTemplate string expectedEditAtLineAndWaitTemplate string expectedSuspend bool }{ { "Default template is vim", &OSConfig{}, func() string { return "" }, "vim -- {{filename}}", "vim +{{line}} -- {{filename}}", "vim +{{line}} -- {{filename}}", true, }, { "Setting a preset", &OSConfig{ EditPreset: "vscode", }, func() string { return "" }, "code --reuse-window -- {{filename}}", "code --reuse-window --goto -- {{filename}}:{{line}}", "code --reuse-window --goto --wait -- {{filename}}:{{line}}", false, }, { "Setting a preset wins over guessed editor", &OSConfig{ EditPreset: "vscode", }, func() string { return "nano" }, "code --reuse-window -- {{filename}}", "code --reuse-window --goto -- {{filename}}:{{line}}", "code --reuse-window --goto --wait -- {{filename}}:{{line}}", false, }, { "Overriding a preset with explicit config (edit)", &OSConfig{ EditPreset: "vscode", Edit: "myeditor {{filename}}", SuspendOnEdit: &trueVal, }, func() string { return "" }, "myeditor {{filename}}", "code --reuse-window --goto -- {{filename}}:{{line}}", "code --reuse-window --goto --wait -- {{filename}}:{{line}}", true, }, { "Overriding a preset with explicit config (edit at line)", &OSConfig{ EditPreset: "vscode", EditAtLine: "myeditor --line={{line}} {{filename}}", SuspendOnEdit: &trueVal, }, func() string { return "" }, "code --reuse-window -- {{filename}}", "myeditor --line={{line}} {{filename}}", "code --reuse-window --goto --wait -- {{filename}}:{{line}}", true, }, { "Overriding a preset with explicit config (edit at line and wait)", &OSConfig{ EditPreset: "vscode", EditAtLineAndWait: "myeditor --line={{line}} -w {{filename}}", SuspendOnEdit: &trueVal, }, func() string { return "" }, "code --reuse-window -- {{filename}}", "code --reuse-window --goto -- {{filename}}:{{line}}", "myeditor --line={{line}} -w {{filename}}", true, }, { "Unknown preset name", &OSConfig{ EditPreset: "thisPresetDoesNotExist", }, func() string { return "" }, "vim -- {{filename}}", "vim +{{line}} -- {{filename}}", "vim +{{line}} -- {{filename}}", true, }, { "Guessing a preset from guessed editor", &OSConfig{}, func() string { return "emacs" }, "emacs -- {{filename}}", "emacs +{{line}} -- {{filename}}", "emacs +{{line}} -- {{filename}}", true, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { template, suspend := GetEditTemplate("bash", s.osConfig, s.guessDefaultEditor) assert.Equal(t, s.expectedEditTemplate, template) assert.Equal(t, s.expectedSuspend, suspend) template, suspend = GetEditAtLineTemplate("bash", s.osConfig, s.guessDefaultEditor) assert.Equal(t, s.expectedEditAtLineTemplate, template) assert.Equal(t, s.expectedSuspend, suspend) template = GetEditAtLineAndWaitTemplate("bash", s.osConfig, s.guessDefaultEditor) assert.Equal(t, s.expectedEditAtLineAndWaitTemplate, template) }) } } lazygit-0.50.0+ds1/pkg/config/keynames.go000066400000000000000000000054501500612110400201250ustar00rootroot00000000000000package config import ( "strings" "unicode/utf8" "github.com/jesseduffield/gocui" "github.com/samber/lo" ) // NOTE: if you make changes to this table, be sure to update // docs/keybindings/Custom_Keybindings.md as well var LabelByKey = map[gocui.Key]string{ gocui.KeyF1: "", gocui.KeyF2: "", gocui.KeyF3: "", gocui.KeyF4: "", gocui.KeyF5: "", gocui.KeyF6: "", gocui.KeyF7: "", gocui.KeyF8: "", gocui.KeyF9: "", gocui.KeyF10: "", gocui.KeyF11: "", gocui.KeyF12: "", gocui.KeyInsert: "", gocui.KeyDelete: "", gocui.KeyHome: "", gocui.KeyEnd: "", gocui.KeyPgup: "", gocui.KeyPgdn: "", gocui.KeyArrowUp: "", gocui.KeyShiftArrowUp: "", gocui.KeyArrowDown: "", gocui.KeyShiftArrowDown: "", gocui.KeyArrowLeft: "", gocui.KeyArrowRight: "", gocui.KeyTab: "", // gocui.KeyBacktab: "", gocui.KeyEnter: "", // gocui.KeyAltEnter: "", gocui.KeyEsc: "", // , gocui.KeyBackspace: "", // gocui.KeyCtrlSpace: "", // , gocui.KeyCtrlSlash: "", // gocui.KeySpace: "", gocui.KeyCtrlA: "", gocui.KeyCtrlB: "", gocui.KeyCtrlC: "", gocui.KeyCtrlD: "", gocui.KeyCtrlE: "", gocui.KeyCtrlF: "", gocui.KeyCtrlG: "", gocui.KeyCtrlJ: "", gocui.KeyCtrlK: "", gocui.KeyCtrlL: "", gocui.KeyCtrlN: "", gocui.KeyCtrlO: "", gocui.KeyCtrlP: "", gocui.KeyCtrlQ: "", gocui.KeyCtrlR: "", gocui.KeyCtrlS: "", gocui.KeyCtrlT: "", gocui.KeyCtrlU: "", gocui.KeyCtrlV: "", gocui.KeyCtrlW: "", gocui.KeyCtrlX: "", gocui.KeyCtrlY: "", gocui.KeyCtrlZ: "", gocui.KeyCtrl4: "", // gocui.KeyCtrl5: "", // gocui.KeyCtrl6: "", gocui.KeyCtrl8: "", gocui.MouseWheelUp: "mouse wheel up", gocui.MouseWheelDown: "mouse wheel down", } var KeyByLabel = lo.Invert(LabelByKey) func isValidKeybindingKey(key string) bool { runeCount := utf8.RuneCountInString(key) if key == "" { return true } if runeCount > 1 { _, ok := KeyByLabel[strings.ToLower(key)] return ok } return true } lazygit-0.50.0+ds1/pkg/config/user_config.go000066400000000000000000001451411500612110400206160ustar00rootroot00000000000000package config import ( "time" "github.com/karimkhaleel/jsonschema" ) type UserConfig struct { // Config relating to the Lazygit UI Gui GuiConfig `yaml:"gui"` // Config relating to git Git GitConfig `yaml:"git"` // Periodic update checks Update UpdateConfig `yaml:"update"` // Background refreshes Refresher RefresherConfig `yaml:"refresher"` // If true, show a confirmation popup before quitting Lazygit ConfirmOnQuit bool `yaml:"confirmOnQuit"` // If true, exit Lazygit when the user presses escape in a context where there is nothing to cancel/close QuitOnTopLevelReturn bool `yaml:"quitOnTopLevelReturn"` // Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc OS OSConfig `yaml:"os,omitempty"` // If true, don't display introductory popups upon opening Lazygit. DisableStartupPopups bool `yaml:"disableStartupPopups"` // User-configured commands that can be invoked from within Lazygit // See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md CustomCommands []CustomCommand `yaml:"customCommands" jsonschema:"uniqueItems=true"` // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-pull-request-urls Services map[string]string `yaml:"services"` // What to do when opening Lazygit outside of a git repo. // - 'prompt': (default) ask whether to initialize a new repo or open in the most recent repo // - 'create': initialize a new repo // - 'skip': open most recent repo // - 'quit': exit Lazygit NotARepository string `yaml:"notARepository" jsonschema:"enum=prompt,enum=create,enum=skip,enum=quit"` // If true, display a confirmation when subprocess terminates. This allows you to view the output of the subprocess before returning to Lazygit. PromptToReturnFromSubprocess bool `yaml:"promptToReturnFromSubprocess"` // Keybindings Keybinding KeybindingConfig `yaml:"keybinding"` } type RefresherConfig struct { // File/submodule refresh interval in seconds. // Auto-refresh can be disabled via option 'git.autoRefresh'. RefreshInterval int `yaml:"refreshInterval" jsonschema:"minimum=0"` // Re-fetch interval in seconds. // Auto-fetch can be disabled via option 'git.autoFetch'. FetchInterval int `yaml:"fetchInterval" jsonschema:"minimum=0"` } type GuiConfig struct { // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color AuthorColors map[string]string `yaml:"authorColors"` // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color // Deprecated: use branchColorPatterns instead BranchColors map[string]string `yaml:"branchColors" jsonschema:"deprecated"` // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color BranchColorPatterns map[string]string `yaml:"branchColorPatterns"` // Custom icons for filenames and file extensions // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color CustomIcons CustomIconsConfig `yaml:"customIcons"` // The number of lines you scroll by when scrolling the main window ScrollHeight int `yaml:"scrollHeight" jsonschema:"minimum=1"` // If true, allow scrolling past the bottom of the content in the main window ScrollPastBottom bool `yaml:"scrollPastBottom"` // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#scroll-off-margin ScrollOffMargin int `yaml:"scrollOffMargin"` // One of: 'margin' (default) | 'jump' ScrollOffBehavior string `yaml:"scrollOffBehavior"` // The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs. // Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command. TabWidth int `yaml:"tabWidth" jsonschema:"minimum=1"` // If true, capture mouse events. // When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS. MouseEvents bool `yaml:"mouseEvents"` // If true, do not show a warning when discarding changes in the staging view. SkipDiscardChangeWarning bool `yaml:"skipDiscardChangeWarning"` // If true, do not show warning when applying/popping the stash SkipStashWarning bool `yaml:"skipStashWarning"` // If true, do not show a warning when attempting to commit without any staged files; instead stage all unstaged files. SkipNoStagedFilesWarning bool `yaml:"skipNoStagedFilesWarning"` // If true, do not show a warning when rewording a commit via an external editor SkipRewordInEditorWarning bool `yaml:"skipRewordInEditorWarning"` // Fraction of the total screen width to use for the left side section. You may want to pick a small number (e.g. 0.2) if you're using a narrow screen, so that you can see more of the main section. // Number from 0 to 1.0. SidePanelWidth float64 `yaml:"sidePanelWidth" jsonschema:"maximum=1,minimum=0"` // If true, increase the height of the focused side window; creating an accordion effect. ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"` // The weight of the expanded side panel, relative to the other panels. 2 means // twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true. ExpandedSidePanelWeight int `yaml:"expandedSidePanelWeight"` // Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split. // Options are: // - 'horizontal': split the window horizontally // - 'vertical': split the window vertically // - 'flexible': (default) split the window horizontally if the window is wide enough, otherwise split vertically MainPanelSplitMode string `yaml:"mainPanelSplitMode" jsonschema:"enum=horizontal,enum=flexible,enum=vertical"` // How the window is split when in half screen mode (i.e. after hitting '+' once). // Possible values: // - 'left': split the window horizontally (side panel on the left, main view on the right) // - 'top': split the window vertically (side panel on top, main view below) EnlargedSideViewLocation string `yaml:"enlargedSideViewLocation"` // If true, wrap lines in the staging view to the width of the view. This // makes it much easier to work with diffs that have long lines, e.g. // paragraphs of markdown text. WrapLinesInStagingView bool `yaml:"wrapLinesInStagingView"` // One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' Language string `yaml:"language" jsonschema:"enum=auto,enum=en,enum=zh-TW,enum=zh-CN,enum=pl,enum=nl,enum=ja,enum=ko,enum=ru"` // Format used when displaying time e.g. commit time. // Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format TimeFormat string `yaml:"timeFormat"` // Format used when displaying time if the time is less than 24 hours ago. // Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format ShortTimeFormat string `yaml:"shortTimeFormat"` // Config relating to colors and styles. // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes Theme ThemeConfig `yaml:"theme"` // Config relating to the commit length indicator CommitLength CommitLengthConfig `yaml:"commitLength"` // If true, show the '5 of 20' footer at the bottom of list views ShowListFooter bool `yaml:"showListFooter"` // If true, display the files in the file views as a tree. If false, display the files as a flat list. // This can be toggled from within Lazygit with the '`' key, but that will not change the default. ShowFileTree bool `yaml:"showFileTree"` // If true, show the number of lines changed per file in the Files view ShowNumstatInFilesView bool `yaml:"showNumstatInFilesView"` // If true, show a random tip in the command log when Lazygit starts ShowRandomTip bool `yaml:"showRandomTip"` // If true, show the command log ShowCommandLog bool `yaml:"showCommandLog"` // If true, show the bottom line that contains keybinding info and useful buttons. If false, this line will be hidden except to display a loader for an in-progress action. ShowBottomLine bool `yaml:"showBottomLine"` // If true, show jump-to-window keybindings in window titles. ShowPanelJumps bool `yaml:"showPanelJumps"` // Deprecated: use nerdFontsVersion instead ShowIcons bool `yaml:"showIcons" jsonschema:"deprecated"` // Nerd fonts version to use. // One of: '2' | '3' | empty string (default) // If empty, do not show icons. NerdFontsVersion string `yaml:"nerdFontsVersion" jsonschema:"enum=2,enum=3,enum="` // If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty. ShowFileIcons bool `yaml:"showFileIcons"` // Length of author name in (non-expanded) commits view. 2 means show initials only. CommitAuthorShortLength int `yaml:"commitAuthorShortLength"` // Length of author name in expanded commits view. 2 means show initials only. CommitAuthorLongLength int `yaml:"commitAuthorLongLength"` // Length of commit hash in commits view. 0 shows '*' if NF icons aren't on. CommitHashLength int `yaml:"commitHashLength" jsonschema:"minimum=0"` // If true, show commit hashes alongside branch names in the branches view. ShowBranchCommitHash bool `yaml:"showBranchCommitHash"` // Whether to show the divergence from the base branch in the branches view. // One of: 'none' | 'onlyArrow' | 'arrowAndNumber' ShowDivergenceFromBaseBranch string `yaml:"showDivergenceFromBaseBranch" jsonschema:"enum=none,enum=onlyArrow,enum=arrowAndNumber"` // Height of the command log view CommandLogSize int `yaml:"commandLogSize" jsonschema:"minimum=0"` // Whether to split the main window when viewing file changes. // One of: 'auto' | 'always' // If 'auto', only split the main window when a file has both staged and unstaged changes SplitDiff string `yaml:"splitDiff" jsonschema:"enum=auto,enum=always"` // Default size for focused window. Can be changed from within Lazygit with '+' and '_' (but this won't change the default). // One of: 'normal' (default) | 'half' | 'full' ScreenMode string `yaml:"screenMode" jsonschema:"enum=normal,enum=half,enum=full"` // Window border style. // One of 'rounded' (default) | 'single' | 'double' | 'hidden' Border string `yaml:"border" jsonschema:"enum=single,enum=double,enum=rounded,enum=hidden"` // If true, show a seriously epic explosion animation when nuking the working tree. AnimateExplosion bool `yaml:"animateExplosion"` // Whether to stack UI components on top of each other. // One of 'auto' (default) | 'always' | 'never' PortraitMode string `yaml:"portraitMode"` // How things are filtered when typing '/'. // One of 'substring' (default) | 'fuzzy' FilterMode string `yaml:"filterMode" jsonschema:"enum=substring,enum=fuzzy"` // Config relating to the spinner. Spinner SpinnerConfig `yaml:"spinner"` // Status panel view. // One of 'dashboard' (default) | 'allBranchesLog' StatusPanelView string `yaml:"statusPanelView" jsonschema:"enum=dashboard,enum=allBranchesLog"` // If true, jump to the Files panel after popping a stash SwitchToFilesAfterStashPop bool `yaml:"switchToFilesAfterStashPop"` // If true, jump to the Files panel after applying a stash SwitchToFilesAfterStashApply bool `yaml:"switchToFilesAfterStashApply"` // If true, when using the panel jump keys (default 1 through 5) and target panel is already active, go to next tab instead SwitchTabsWithPanelJumpKeys bool `yaml:"switchTabsWithPanelJumpKeys"` } func (c *GuiConfig) UseFuzzySearch() bool { return c.FilterMode == "fuzzy" } type ThemeConfig struct { // Border color of focused window ActiveBorderColor []string `yaml:"activeBorderColor" jsonschema:"minItems=1,uniqueItems=true"` // Border color of non-focused windows InactiveBorderColor []string `yaml:"inactiveBorderColor" jsonschema:"minItems=1,uniqueItems=true"` // Border color of focused window when searching in that window SearchingActiveBorderColor []string `yaml:"searchingActiveBorderColor" jsonschema:"minItems=1,uniqueItems=true"` // Color of keybindings help text in the bottom line OptionsTextColor []string `yaml:"optionsTextColor" jsonschema:"minItems=1,uniqueItems=true"` // Background color of selected line. // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line SelectedLineBgColor []string `yaml:"selectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"` // Background color of selected line when view doesn't have focus. InactiveViewSelectedLineBgColor []string `yaml:"inactiveViewSelectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"` // Foreground color of copied commit CherryPickedCommitFgColor []string `yaml:"cherryPickedCommitFgColor" jsonschema:"minItems=1,uniqueItems=true"` // Background color of copied commit CherryPickedCommitBgColor []string `yaml:"cherryPickedCommitBgColor" jsonschema:"minItems=1,uniqueItems=true"` // Foreground color of marked base commit (for rebase) MarkedBaseCommitFgColor []string `yaml:"markedBaseCommitFgColor"` // Background color of marked base commit (for rebase) MarkedBaseCommitBgColor []string `yaml:"markedBaseCommitBgColor"` // Color for file with unstaged changes UnstagedChangesColor []string `yaml:"unstagedChangesColor" jsonschema:"minItems=1,uniqueItems=true"` // Default text color DefaultFgColor []string `yaml:"defaultFgColor" jsonschema:"minItems=1,uniqueItems=true"` } type CommitLengthConfig struct { // If true, show an indicator of commit message length Show bool `yaml:"show"` } type SpinnerConfig struct { // The frames of the spinner animation. Frames []string `yaml:"frames"` // The "speed" of the spinner in milliseconds. Rate int `yaml:"rate" jsonschema:"minimum=1"` } type GitConfig struct { // See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md Paging PagingConfig `yaml:"paging"` // Config relating to committing Commit CommitConfig `yaml:"commit"` // Config relating to merging Merging MergingConfig `yaml:"merging"` // list of branches that are considered 'main' branches, used when displaying commits MainBranches []string `yaml:"mainBranches" jsonschema:"uniqueItems=true"` // Prefix to use when skipping hooks. E.g. if set to 'WIP', then pre-commit hooks will be skipped when the commit message starts with 'WIP' SkipHookPrefix string `yaml:"skipHookPrefix"` // If true, periodically fetch from remote AutoFetch bool `yaml:"autoFetch"` // If true, periodically refresh files and submodules AutoRefresh bool `yaml:"autoRefresh"` // If not "none", lazygit will automatically forward branches to their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). // Possible values: 'none' | 'onlyMainBranches' | 'allBranches' AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"` // If true, pass the --all arg to git fetch FetchAll bool `yaml:"fetchAll"` // If true, lazygit will automatically stage files that used to have merge // conflicts but no longer do; and it will also ask you if you want to // continue a merge or rebase if you've resolved all conflicts. If false, it // won't do either of these things. AutoStageResolvedConflicts bool `yaml:"autoStageResolvedConflicts"` // Command used when displaying the current branch git log in the main window BranchLogCmd string `yaml:"branchLogCmd"` // Command used to display git log of all branches in the main window. // Deprecated: Use `allBranchesLogCmds` instead. AllBranchesLogCmd string `yaml:"allBranchesLogCmd" jsonschema:"deprecated"` // Commands used to display git log of all branches in the main window, they will be cycled in order of appearance (array of strings) AllBranchesLogCmds []string `yaml:"allBranchesLogCmds"` // If true, do not spawn a separate process when using GPG OverrideGpg bool `yaml:"overrideGpg"` // If true, do not allow force pushes DisableForcePushing bool `yaml:"disableForcePushing"` // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-commit-message-prefix CommitPrefix []CommitPrefixConfig `yaml:"commitPrefix"` // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-commit-message-prefix CommitPrefixes map[string][]CommitPrefixConfig `yaml:"commitPrefixes"` // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-branch-name-prefix BranchPrefix string `yaml:"branchPrefix"` // If true, parse emoji strings in commit messages e.g. render :rocket: as 🚀 // (This should really be under 'gui', not 'git') ParseEmoji bool `yaml:"parseEmoji"` // Config for showing the log in the commits view Log LogConfig `yaml:"log"` // When copying commit hashes to the clipboard, truncate them to this // length. Set to 40 to disable truncation. TruncateCopiedCommitHashesTo int `yaml:"truncateCopiedCommitHashesTo"` } type PagerType string func (PagerType) JSONSchemaExtend(schema *jsonschema.Schema) { schema.Examples = []any{ "delta --dark --paging=never", "diff-so-fancy", "ydiff -p cat -s --wrap --width={{columnWidth}}", } } type PagingConfig struct { // Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never' ColorArg string `yaml:"colorArg" jsonschema:"enum=always,enum=never"` // e.g. // diff-so-fancy // delta --dark --paging=never // ydiff -p cat -s --wrap --width={{columnWidth}} Pager PagerType `yaml:"pager"` // If true, Lazygit will use whatever pager is specified in `$GIT_PAGER`, `$PAGER`, or your *git config*. If the pager ends with something like ` | less` we will strip that part out, because less doesn't play nice with our rendering approach. If the custom pager uses less under the hood, that will also break rendering (hence the `--paging=never` flag for the `delta` pager). UseConfig bool `yaml:"useConfig"` // e.g. 'difft --color=always' ExternalDiffCommand string `yaml:"externalDiffCommand"` } type CommitConfig struct { // If true, pass '--signoff' flag when committing SignOff bool `yaml:"signOff"` // Automatic WYSIWYG wrapping of the commit message as you type AutoWrapCommitMessage bool `yaml:"autoWrapCommitMessage"` // If autoWrapCommitMessage is true, the width to wrap to AutoWrapWidth int `yaml:"autoWrapWidth"` } type MergingConfig struct { // If true, run merges in a subprocess so that if a commit message is required, Lazygit will not hang // Only applicable to unix users. ManualCommit bool `yaml:"manualCommit"` // Extra args passed to `git merge`, e.g. --no-ff Args string `yaml:"args" jsonschema:"example=--no-ff"` // The commit message to use for a squash merge commit. Can contain "{{selectedRef}}" and "{{currentBranch}}" placeholders. SquashMergeMessage string `yaml:"squashMergeMessage"` } type LogConfig struct { // One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default' // 'topo-order' makes it easier to read the git log graph, but commits may not // appear chronologically. See https://git-scm.com/docs/ // // Deprecated: Configure this with `Log menu -> Commit sort order` ( in the commits window by default). Order string `yaml:"order" jsonschema:"deprecated,enum=date-order,enum=author-date-order,enum=topo-order,enum=default,deprecated"` // This determines whether the git graph is rendered in the commits panel // One of 'always' | 'never' | 'when-maximised' // // Deprecated: Configure this with `Log menu -> Show git graph` ( in the commits window by default). ShowGraph string `yaml:"showGraph" jsonschema:"deprecated,enum=always,enum=never,enum=when-maximised"` // displays the whole git graph by default in the commits view (equivalent to passing the `--all` argument to `git log`) ShowWholeGraph bool `yaml:"showWholeGraph"` } type CommitPrefixConfig struct { // pattern to match on. E.g. for 'feature/AB-123' to match on the AB-123 use "^\\w+\\/(\\w+-\\w+).*" Pattern string `yaml:"pattern" jsonschema:"example=^\\w+\\/(\\w+-\\w+).*"` // Replace directive. E.g. for 'feature/AB-123' to start the commit message with 'AB-123 ' use "[$1] " Replace string `yaml:"replace" jsonschema:"example=[$1]"` } type UpdateConfig struct { // One of: 'prompt' (default) | 'background' | 'never' Method string `yaml:"method" jsonschema:"enum=prompt,enum=background,enum=never"` // Period in days between update checks Days int64 `yaml:"days" jsonschema:"minimum=0"` } type KeybindingConfig struct { Universal KeybindingUniversalConfig `yaml:"universal"` Status KeybindingStatusConfig `yaml:"status"` Files KeybindingFilesConfig `yaml:"files"` Branches KeybindingBranchesConfig `yaml:"branches"` Worktrees KeybindingWorktreesConfig `yaml:"worktrees"` Commits KeybindingCommitsConfig `yaml:"commits"` AmendAttribute KeybindingAmendAttributeConfig `yaml:"amendAttribute"` Stash KeybindingStashConfig `yaml:"stash"` CommitFiles KeybindingCommitFilesConfig `yaml:"commitFiles"` Main KeybindingMainConfig `yaml:"main"` Submodules KeybindingSubmodulesConfig `yaml:"submodules"` CommitMessage KeybindingCommitMessageConfig `yaml:"commitMessage"` } // damn looks like we have some inconsistencies here with -alt and -alt1 type KeybindingUniversalConfig struct { Quit string `yaml:"quit"` QuitAlt1 string `yaml:"quit-alt1"` Return string `yaml:"return"` QuitWithoutChangingDirectory string `yaml:"quitWithoutChangingDirectory"` TogglePanel string `yaml:"togglePanel"` PrevItem string `yaml:"prevItem"` NextItem string `yaml:"nextItem"` PrevItemAlt string `yaml:"prevItem-alt"` NextItemAlt string `yaml:"nextItem-alt"` PrevPage string `yaml:"prevPage"` NextPage string `yaml:"nextPage"` ScrollLeft string `yaml:"scrollLeft"` ScrollRight string `yaml:"scrollRight"` GotoTop string `yaml:"gotoTop"` GotoBottom string `yaml:"gotoBottom"` GotoTopAlt string `yaml:"gotoTop-alt"` GotoBottomAlt string `yaml:"gotoBottom-alt"` ToggleRangeSelect string `yaml:"toggleRangeSelect"` RangeSelectDown string `yaml:"rangeSelectDown"` RangeSelectUp string `yaml:"rangeSelectUp"` PrevBlock string `yaml:"prevBlock"` NextBlock string `yaml:"nextBlock"` PrevBlockAlt string `yaml:"prevBlock-alt"` NextBlockAlt string `yaml:"nextBlock-alt"` NextBlockAlt2 string `yaml:"nextBlock-alt2"` PrevBlockAlt2 string `yaml:"prevBlock-alt2"` JumpToBlock []string `yaml:"jumpToBlock"` FocusMainView string `yaml:"focusMainView"` NextMatch string `yaml:"nextMatch"` PrevMatch string `yaml:"prevMatch"` StartSearch string `yaml:"startSearch"` OptionMenu string `yaml:"optionMenu"` OptionMenuAlt1 string `yaml:"optionMenu-alt1"` Select string `yaml:"select"` GoInto string `yaml:"goInto"` Confirm string `yaml:"confirm"` ConfirmInEditor string `yaml:"confirmInEditor"` Remove string `yaml:"remove"` New string `yaml:"new"` Edit string `yaml:"edit"` OpenFile string `yaml:"openFile"` ScrollUpMain string `yaml:"scrollUpMain"` ScrollDownMain string `yaml:"scrollDownMain"` ScrollUpMainAlt1 string `yaml:"scrollUpMain-alt1"` ScrollDownMainAlt1 string `yaml:"scrollDownMain-alt1"` ScrollUpMainAlt2 string `yaml:"scrollUpMain-alt2"` ScrollDownMainAlt2 string `yaml:"scrollDownMain-alt2"` ExecuteShellCommand string `yaml:"executeShellCommand"` CreateRebaseOptionsMenu string `yaml:"createRebaseOptionsMenu"` Push string `yaml:"pushFiles"` // 'Files' appended for legacy reasons Pull string `yaml:"pullFiles"` // 'Files' appended for legacy reasons Refresh string `yaml:"refresh"` CreatePatchOptionsMenu string `yaml:"createPatchOptionsMenu"` NextTab string `yaml:"nextTab"` PrevTab string `yaml:"prevTab"` NextScreenMode string `yaml:"nextScreenMode"` PrevScreenMode string `yaml:"prevScreenMode"` Undo string `yaml:"undo"` Redo string `yaml:"redo"` FilteringMenu string `yaml:"filteringMenu"` DiffingMenu string `yaml:"diffingMenu"` DiffingMenuAlt string `yaml:"diffingMenu-alt"` CopyToClipboard string `yaml:"copyToClipboard"` OpenRecentRepos string `yaml:"openRecentRepos"` SubmitEditorText string `yaml:"submitEditorText"` ExtrasMenu string `yaml:"extrasMenu"` ToggleWhitespaceInDiffView string `yaml:"toggleWhitespaceInDiffView"` IncreaseContextInDiffView string `yaml:"increaseContextInDiffView"` DecreaseContextInDiffView string `yaml:"decreaseContextInDiffView"` IncreaseRenameSimilarityThreshold string `yaml:"increaseRenameSimilarityThreshold"` DecreaseRenameSimilarityThreshold string `yaml:"decreaseRenameSimilarityThreshold"` OpenDiffTool string `yaml:"openDiffTool"` } type KeybindingStatusConfig struct { CheckForUpdate string `yaml:"checkForUpdate"` RecentRepos string `yaml:"recentRepos"` AllBranchesLogGraph string `yaml:"allBranchesLogGraph"` } type KeybindingFilesConfig struct { CommitChanges string `yaml:"commitChanges"` CommitChangesWithoutHook string `yaml:"commitChangesWithoutHook"` AmendLastCommit string `yaml:"amendLastCommit"` CommitChangesWithEditor string `yaml:"commitChangesWithEditor"` FindBaseCommitForFixup string `yaml:"findBaseCommitForFixup"` ConfirmDiscard string `yaml:"confirmDiscard"` IgnoreFile string `yaml:"ignoreFile"` RefreshFiles string `yaml:"refreshFiles"` StashAllChanges string `yaml:"stashAllChanges"` ViewStashOptions string `yaml:"viewStashOptions"` ToggleStagedAll string `yaml:"toggleStagedAll"` ViewResetOptions string `yaml:"viewResetOptions"` Fetch string `yaml:"fetch"` ToggleTreeView string `yaml:"toggleTreeView"` OpenMergeTool string `yaml:"openMergeTool"` OpenStatusFilter string `yaml:"openStatusFilter"` CopyFileInfoToClipboard string `yaml:"copyFileInfoToClipboard"` CollapseAll string `yaml:"collapseAll"` ExpandAll string `yaml:"expandAll"` } type KeybindingBranchesConfig struct { CreatePullRequest string `yaml:"createPullRequest"` ViewPullRequestOptions string `yaml:"viewPullRequestOptions"` CopyPullRequestURL string `yaml:"copyPullRequestURL"` CheckoutBranchByName string `yaml:"checkoutBranchByName"` ForceCheckoutBranch string `yaml:"forceCheckoutBranch"` RebaseBranch string `yaml:"rebaseBranch"` RenameBranch string `yaml:"renameBranch"` MergeIntoCurrentBranch string `yaml:"mergeIntoCurrentBranch"` MoveCommitsToNewBranch string `yaml:"moveCommitsToNewBranch"` ViewGitFlowOptions string `yaml:"viewGitFlowOptions"` FastForward string `yaml:"fastForward"` CreateTag string `yaml:"createTag"` PushTag string `yaml:"pushTag"` SetUpstream string `yaml:"setUpstream"` FetchRemote string `yaml:"fetchRemote"` SortOrder string `yaml:"sortOrder"` } type KeybindingWorktreesConfig struct { ViewWorktreeOptions string `yaml:"viewWorktreeOptions"` } type KeybindingCommitsConfig struct { SquashDown string `yaml:"squashDown"` RenameCommit string `yaml:"renameCommit"` RenameCommitWithEditor string `yaml:"renameCommitWithEditor"` ViewResetOptions string `yaml:"viewResetOptions"` MarkCommitAsFixup string `yaml:"markCommitAsFixup"` CreateFixupCommit string `yaml:"createFixupCommit"` SquashAboveCommits string `yaml:"squashAboveCommits"` MoveDownCommit string `yaml:"moveDownCommit"` MoveUpCommit string `yaml:"moveUpCommit"` AmendToCommit string `yaml:"amendToCommit"` ResetCommitAuthor string `yaml:"resetCommitAuthor"` PickCommit string `yaml:"pickCommit"` RevertCommit string `yaml:"revertCommit"` CherryPickCopy string `yaml:"cherryPickCopy"` PasteCommits string `yaml:"pasteCommits"` MarkCommitAsBaseForRebase string `yaml:"markCommitAsBaseForRebase"` CreateTag string `yaml:"tagCommit"` CheckoutCommit string `yaml:"checkoutCommit"` ResetCherryPick string `yaml:"resetCherryPick"` CopyCommitAttributeToClipboard string `yaml:"copyCommitAttributeToClipboard"` OpenLogMenu string `yaml:"openLogMenu"` OpenInBrowser string `yaml:"openInBrowser"` ViewBisectOptions string `yaml:"viewBisectOptions"` StartInteractiveRebase string `yaml:"startInteractiveRebase"` SelectCommitsOfCurrentBranch string `yaml:"selectCommitsOfCurrentBranch"` } type KeybindingAmendAttributeConfig struct { ResetAuthor string `yaml:"resetAuthor"` SetAuthor string `yaml:"setAuthor"` AddCoAuthor string `yaml:"addCoAuthor"` } type KeybindingStashConfig struct { PopStash string `yaml:"popStash"` RenameStash string `yaml:"renameStash"` } type KeybindingCommitFilesConfig struct { CheckoutCommitFile string `yaml:"checkoutCommitFile"` } type KeybindingMainConfig struct { ToggleSelectHunk string `yaml:"toggleSelectHunk"` PickBothHunks string `yaml:"pickBothHunks"` EditSelectHunk string `yaml:"editSelectHunk"` } type KeybindingSubmodulesConfig struct { Init string `yaml:"init"` Update string `yaml:"update"` BulkMenu string `yaml:"bulkMenu"` } type KeybindingCommitMessageConfig struct { CommitMenu string `yaml:"commitMenu"` } // OSConfig contains config on the level of the os type OSConfig struct { // Command for editing a file. Should contain "{{filename}}". Edit string `yaml:"edit,omitempty"` // Command for editing a file at a given line number. Should contain // "{{filename}}", and may optionally contain "{{line}}". EditAtLine string `yaml:"editAtLine,omitempty"` // Same as EditAtLine, except that the command needs to wait until the // window is closed. EditAtLineAndWait string `yaml:"editAtLineAndWait,omitempty"` // Whether lazygit suspends until an edit process returns // [dev] Pointer to bool so that we can distinguish unset (nil) from false. // [dev] We're naming this `editInTerminal` for backwards compatibility SuspendOnEdit *bool `yaml:"editInTerminal,omitempty"` // For opening a directory in an editor OpenDirInEditor string `yaml:"openDirInEditor,omitempty"` // A built-in preset that sets all of the above settings. Supported presets // are defined in the getPreset function in editor_presets.go. EditPreset string `yaml:"editPreset,omitempty" jsonschema:"example=vim,example=nvim,example=emacs,example=nano,example=vscode,example=sublime,example=kakoune,example=helix,example=xcode,example=zed,example=acme"` // Command for opening a file, as if the file is double-clicked. Should // contain "{{filename}}", but doesn't support "{{line}}". Open string `yaml:"open,omitempty"` // Command for opening a link. Should contain "{{link}}". OpenLink string `yaml:"openLink,omitempty"` // CopyToClipboardCmd is the command for copying to clipboard. // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard CopyToClipboardCmd string `yaml:"copyToClipboardCmd,omitempty"` // ReadFromClipboardCmd is the command for reading the clipboard. // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard ReadFromClipboardCmd string `yaml:"readFromClipboardCmd,omitempty"` // A shell startup file containing shell aliases or shell functions. This will be sourced before running any shell commands, so that shell functions are available in the `:` command prompt or even in custom commands. // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#using-aliases-or-functions-in-shell-commands ShellFunctionsFile string `yaml:"shellFunctionsFile"` // -------- // The following configs are all deprecated and kept for backward // compatibility. They will be removed in the future. // EditCommand is the command for editing a file. // Deprecated: use Edit instead. Note that semantics are different: // EditCommand is just the command itself, whereas Edit contains a // "{{filename}}" variable. EditCommand string `yaml:"editCommand,omitempty" jsonschema:"deprecated"` // EditCommandTemplate is the command template for editing a file // Deprecated: use EditAtLine instead. EditCommandTemplate string `yaml:"editCommandTemplate,omitempty" jsonschema:"deprecated"` // OpenCommand is the command for opening a file // Deprecated: use Open instead. OpenCommand string `yaml:"openCommand,omitempty" jsonschema:"deprecated"` // OpenLinkCommand is the command for opening a link // Deprecated: use OpenLink instead. OpenLinkCommand string `yaml:"openLinkCommand,omitempty" jsonschema:"deprecated"` } type CustomCommandAfterHook struct { CheckForConflicts bool `yaml:"checkForConflicts"` } type CustomCommand struct { // The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md Key string `yaml:"key"` // Instead of defining a single custom command, create a menu of custom commands. Useful for grouping related commands together under a single keybinding, and for keeping them out of the global keybindings menu. // When using this, all other fields except Key and Description are ignored and must be empty. CommandMenu []CustomCommand `yaml:"commandMenu"` // The context in which to listen for the key. Valid values are: status, files, worktrees, localBranches, remotes, remoteBranches, tags, commits, reflogCommits, subCommits, commitFiles, stash, and global. Multiple contexts separated by comma are allowed; most useful for "commits, subCommits" or "files, commitFiles". Context string `yaml:"context" jsonschema:"example=status,example=files,example=worktrees,example=localBranches,example=remotes,example=remoteBranches,example=tags,example=commits,example=reflogCommits,example=subCommits,example=commitFiles,example=stash,example=global"` // The command to run (using Go template syntax for placeholder values) Command string `yaml:"command" jsonschema:"example=git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD"` // If true, run the command in a subprocess (e.g. if the command requires user input) // [dev] Pointer to bool so that we can distinguish unset (nil) from false. Subprocess *bool `yaml:"subprocess"` // A list of prompts that will request user input before running the final command Prompts []CustomCommandPrompt `yaml:"prompts"` // Text to display while waiting for command to finish LoadingText string `yaml:"loadingText" jsonschema:"example=Loading..."` // Label for the custom command when displayed in the keybindings menu Description string `yaml:"description"` // If true, stream the command's output to the Command Log panel // [dev] Pointer to bool so that we can distinguish unset (nil) from false. Stream *bool `yaml:"stream"` // If true, show the command's output in a popup within Lazygit // [dev] Pointer to bool so that we can distinguish unset (nil) from false. ShowOutput *bool `yaml:"showOutput"` // The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title. OutputTitle string `yaml:"outputTitle"` // Actions to take after the command has completed // [dev] Pointer so that we can tell whether it appears in the config file After *CustomCommandAfterHook `yaml:"after"` } func (c *CustomCommand) GetDescription() string { if c.Description != "" { return c.Description } return c.Command } type CustomCommandPrompt struct { // One of: 'input' | 'menu' | 'confirm' | 'menuFromCommand' Type string `yaml:"type"` // Used to reference the entered value from within the custom command. E.g. a prompt with `key: 'Branch'` can be referred to as `{{.Form.Branch}}` in the command Key string `yaml:"key"` // The title to display in the popup panel Title string `yaml:"title"` // The initial value to appear in the text box. // Only for input prompts. InitialValue string `yaml:"initialValue"` // Shows suggestions as the input is entered // Only for input prompts. Suggestions CustomCommandSuggestions `yaml:"suggestions"` // The message of the confirmation prompt. // Only for confirm prompts. Body string `yaml:"body" jsonschema:"example=Are you sure you want to push to the remote?"` // Menu options. // Only for menu prompts. Options []CustomCommandMenuOption `yaml:"options"` // The command to run to generate menu options // Only for menuFromCommand prompts. Command string `yaml:"command" jsonschema:"example=git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD"` // The regexp to run specifying groups which are going to be kept from the command's output. // Only for menuFromCommand prompts. Filter string `yaml:"filter" jsonschema:"example=.*{{.SelectedRemote.Name }}/(?P.*)"` // How to format matched groups from the filter to construct a menu item's value. // Only for menuFromCommand prompts. ValueFormat string `yaml:"valueFormat" jsonschema:"example={{ .branch }}"` // Like valueFormat but for the labels. If `labelFormat` is not specified, `valueFormat` is shown instead. // Only for menuFromCommand prompts. LabelFormat string `yaml:"labelFormat" jsonschema:"example={{ .branch | green }}"` } type CustomCommandSuggestions struct { // Uses built-in logic to obtain the suggestions. One of 'authors' | 'branches' | 'files' | 'refs' | 'remotes' | 'remoteBranches' | 'tags' Preset string `yaml:"preset" jsonschema:"enum=authors,enum=branches,enum=files,enum=refs,enum=remotes,enum=remoteBranches,enum=tags"` // Command to run such that each line in the output becomes a suggestion. Mutually exclusive with 'preset' field. Command string `yaml:"command" jsonschema:"example=git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD"` } type CustomCommandMenuOption struct { // The first part of the label Name string `yaml:"name"` // The second part of the label Description string `yaml:"description"` // The value that will be used in the command Value string `yaml:"value" jsonschema:"example=feature,minLength=1"` } type CustomIconsConfig struct { // Map of filenames to icon properties (icon and color) Filenames map[string]IconProperties `yaml:"filenames"` // Map of file extensions (including the dot) to icon properties (icon and color) Extensions map[string]IconProperties `yaml:"extensions"` } type IconProperties struct { Icon string `yaml:"icon"` Color string `yaml:"color"` } func GetDefaultConfig() *UserConfig { return &UserConfig{ Gui: GuiConfig{ ScrollHeight: 2, ScrollPastBottom: true, ScrollOffMargin: 2, ScrollOffBehavior: "margin", TabWidth: 4, MouseEvents: true, SkipDiscardChangeWarning: false, SkipStashWarning: false, SidePanelWidth: 0.3333, ExpandFocusedSidePanel: false, ExpandedSidePanelWeight: 2, MainPanelSplitMode: "flexible", EnlargedSideViewLocation: "left", WrapLinesInStagingView: true, Language: "auto", TimeFormat: "02 Jan 06", ShortTimeFormat: time.Kitchen, Theme: ThemeConfig{ ActiveBorderColor: []string{"green", "bold"}, SearchingActiveBorderColor: []string{"cyan", "bold"}, InactiveBorderColor: []string{"default"}, OptionsTextColor: []string{"blue"}, SelectedLineBgColor: []string{"blue"}, InactiveViewSelectedLineBgColor: []string{"bold"}, CherryPickedCommitBgColor: []string{"cyan"}, CherryPickedCommitFgColor: []string{"blue"}, MarkedBaseCommitBgColor: []string{"yellow"}, MarkedBaseCommitFgColor: []string{"blue"}, UnstagedChangesColor: []string{"red"}, DefaultFgColor: []string{"default"}, }, CommitLength: CommitLengthConfig{Show: true}, SkipNoStagedFilesWarning: false, ShowListFooter: true, ShowCommandLog: true, ShowBottomLine: true, ShowPanelJumps: true, ShowFileTree: true, ShowNumstatInFilesView: false, ShowRandomTip: true, ShowIcons: false, NerdFontsVersion: "", ShowFileIcons: true, CommitAuthorShortLength: 2, CommitAuthorLongLength: 17, CommitHashLength: 8, ShowBranchCommitHash: false, ShowDivergenceFromBaseBranch: "none", CommandLogSize: 8, SplitDiff: "auto", SkipRewordInEditorWarning: false, ScreenMode: "normal", Border: "rounded", AnimateExplosion: true, PortraitMode: "auto", FilterMode: "substring", Spinner: SpinnerConfig{ Frames: []string{"|", "/", "-", "\\"}, Rate: 50, }, StatusPanelView: "dashboard", SwitchToFilesAfterStashPop: true, SwitchToFilesAfterStashApply: true, SwitchTabsWithPanelJumpKeys: false, }, Git: GitConfig{ Paging: PagingConfig{ ColorArg: "always", Pager: "", UseConfig: false, ExternalDiffCommand: "", }, Commit: CommitConfig{ SignOff: false, AutoWrapCommitMessage: true, AutoWrapWidth: 72, }, Merging: MergingConfig{ ManualCommit: false, Args: "", SquashMergeMessage: "Squash merge {{selectedRef}} into {{currentBranch}}", }, Log: LogConfig{ Order: "topo-order", ShowGraph: "always", ShowWholeGraph: false, }, SkipHookPrefix: "WIP", MainBranches: []string{"master", "main"}, AutoFetch: true, AutoRefresh: true, AutoForwardBranches: "onlyMainBranches", FetchAll: true, AutoStageResolvedConflicts: true, BranchLogCmd: "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --", AllBranchesLogCmd: "git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium", DisableForcePushing: false, CommitPrefixes: map[string][]CommitPrefixConfig(nil), BranchPrefix: "", ParseEmoji: false, TruncateCopiedCommitHashesTo: 12, }, Refresher: RefresherConfig{ RefreshInterval: 10, FetchInterval: 60, }, Update: UpdateConfig{ Method: "prompt", Days: 14, }, ConfirmOnQuit: false, QuitOnTopLevelReturn: false, OS: OSConfig{}, DisableStartupPopups: false, CustomCommands: []CustomCommand(nil), Services: map[string]string(nil), NotARepository: "prompt", PromptToReturnFromSubprocess: true, Keybinding: KeybindingConfig{ Universal: KeybindingUniversalConfig{ Quit: "q", QuitAlt1: "", Return: "", QuitWithoutChangingDirectory: "Q", TogglePanel: "", PrevItem: "", NextItem: "", PrevItemAlt: "k", NextItemAlt: "j", PrevPage: ",", NextPage: ".", ScrollLeft: "H", ScrollRight: "L", GotoTop: "<", GotoBottom: ">", GotoTopAlt: "", GotoBottomAlt: "", ToggleRangeSelect: "v", RangeSelectDown: "", RangeSelectUp: "", PrevBlock: "", NextBlock: "", PrevBlockAlt: "h", NextBlockAlt: "l", PrevBlockAlt2: "", NextBlockAlt2: "", JumpToBlock: []string{"1", "2", "3", "4", "5"}, FocusMainView: "0", NextMatch: "n", PrevMatch: "N", StartSearch: "/", OptionMenu: "", OptionMenuAlt1: "?", Select: "", GoInto: "", Confirm: "", ConfirmInEditor: "", Remove: "d", New: "n", Edit: "e", OpenFile: "o", OpenRecentRepos: "", ScrollUpMain: "", ScrollDownMain: "", ScrollUpMainAlt1: "K", ScrollDownMainAlt1: "J", ScrollUpMainAlt2: "", ScrollDownMainAlt2: "", ExecuteShellCommand: ":", CreateRebaseOptionsMenu: "m", Push: "P", Pull: "p", Refresh: "R", CreatePatchOptionsMenu: "", NextTab: "]", PrevTab: "[", NextScreenMode: "+", PrevScreenMode: "_", Undo: "z", Redo: "", FilteringMenu: "", DiffingMenu: "W", DiffingMenuAlt: "", CopyToClipboard: "", SubmitEditorText: "", ExtrasMenu: "@", ToggleWhitespaceInDiffView: "", IncreaseContextInDiffView: "}", DecreaseContextInDiffView: "{", IncreaseRenameSimilarityThreshold: ")", DecreaseRenameSimilarityThreshold: "(", OpenDiffTool: "", }, Status: KeybindingStatusConfig{ CheckForUpdate: "u", RecentRepos: "", AllBranchesLogGraph: "a", }, Files: KeybindingFilesConfig{ CommitChanges: "c", CommitChangesWithoutHook: "w", AmendLastCommit: "A", CommitChangesWithEditor: "C", FindBaseCommitForFixup: "", IgnoreFile: "i", RefreshFiles: "r", StashAllChanges: "s", ViewStashOptions: "S", ToggleStagedAll: "a", ViewResetOptions: "D", Fetch: "f", ToggleTreeView: "`", OpenMergeTool: "M", OpenStatusFilter: "", ConfirmDiscard: "x", CopyFileInfoToClipboard: "y", CollapseAll: "-", ExpandAll: "=", }, Branches: KeybindingBranchesConfig{ CopyPullRequestURL: "", CreatePullRequest: "o", ViewPullRequestOptions: "O", CheckoutBranchByName: "c", ForceCheckoutBranch: "F", RebaseBranch: "r", RenameBranch: "R", MergeIntoCurrentBranch: "M", MoveCommitsToNewBranch: "N", ViewGitFlowOptions: "i", FastForward: "f", CreateTag: "T", PushTag: "P", SetUpstream: "u", FetchRemote: "f", SortOrder: "s", }, Worktrees: KeybindingWorktreesConfig{ ViewWorktreeOptions: "w", }, Commits: KeybindingCommitsConfig{ SquashDown: "s", RenameCommit: "r", RenameCommitWithEditor: "R", ViewResetOptions: "g", MarkCommitAsFixup: "f", CreateFixupCommit: "F", SquashAboveCommits: "S", MoveDownCommit: "", MoveUpCommit: "", AmendToCommit: "A", ResetCommitAuthor: "a", PickCommit: "p", RevertCommit: "t", CherryPickCopy: "C", PasteCommits: "V", MarkCommitAsBaseForRebase: "B", CreateTag: "T", CheckoutCommit: "", ResetCherryPick: "", CopyCommitAttributeToClipboard: "y", OpenLogMenu: "", OpenInBrowser: "o", ViewBisectOptions: "b", StartInteractiveRebase: "i", SelectCommitsOfCurrentBranch: "*", }, AmendAttribute: KeybindingAmendAttributeConfig{ ResetAuthor: "a", SetAuthor: "A", AddCoAuthor: "c", }, Stash: KeybindingStashConfig{ PopStash: "g", RenameStash: "r", }, CommitFiles: KeybindingCommitFilesConfig{ CheckoutCommitFile: "c", }, Main: KeybindingMainConfig{ ToggleSelectHunk: "a", PickBothHunks: "b", EditSelectHunk: "E", }, Submodules: KeybindingSubmodulesConfig{ Init: "i", Update: "u", BulkMenu: "b", }, CommitMessage: KeybindingCommitMessageConfig{ CommitMenu: "", }, }, } } lazygit-0.50.0+ds1/pkg/config/user_config_validation.go000066400000000000000000000072071500612110400230300ustar00rootroot00000000000000package config import ( "fmt" "log" "reflect" "slices" "strings" "github.com/jesseduffield/lazygit/pkg/constants" ) func (config *UserConfig) Validate() error { if err := validateEnum("gui.statusPanelView", config.Gui.StatusPanelView, []string{"dashboard", "allBranchesLog"}); err != nil { return err } if err := validateEnum("gui.showDivergenceFromBaseBranch", config.Gui.ShowDivergenceFromBaseBranch, []string{"none", "onlyArrow", "arrowAndNumber"}); err != nil { return err } if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches, []string{"none", "onlyMainBranches", "allBranches"}); err != nil { return err } if err := validateKeybindings(config.Keybinding); err != nil { return err } if err := validateCustomCommands(config.CustomCommands); err != nil { return err } return nil } func validateEnum(name string, value string, allowedValues []string) error { if slices.Contains(allowedValues, value) { return nil } allowedValuesStr := strings.Join(allowedValues, ", ") return fmt.Errorf("Unexpected value '%s' for '%s'. Allowed values: %s", value, name, allowedValuesStr) } func validateKeybindingsRecurse(path string, node any) error { value := reflect.ValueOf(node) if value.Kind() == reflect.Struct { for _, field := range reflect.VisibleFields(reflect.TypeOf(node)) { var newPath string if len(path) == 0 { newPath = field.Name } else { newPath = fmt.Sprintf("%s.%s", path, field.Name) } if err := validateKeybindingsRecurse(newPath, value.FieldByName(field.Name).Interface()); err != nil { return err } } } else if value.Kind() == reflect.Slice { for i := 0; i < value.Len(); i++ { if err := validateKeybindingsRecurse( fmt.Sprintf("%s[%d]", path, i), value.Index(i).Interface()); err != nil { return err } } } else if value.Kind() == reflect.String { key := node.(string) if !isValidKeybindingKey(key) { return fmt.Errorf("Unrecognized key '%s' for keybinding '%s'. For permitted values see %s", key, path, constants.Links.Docs.CustomKeybindings) } } else { log.Fatalf("Unexpected type for property '%s': %s", path, value.Kind()) } return nil } func validateKeybindings(keybindingConfig KeybindingConfig) error { if err := validateKeybindingsRecurse("", keybindingConfig); err != nil { return err } if len(keybindingConfig.Universal.JumpToBlock) != 5 { return fmt.Errorf("keybinding.universal.jumpToBlock must have 5 elements; found %d.", len(keybindingConfig.Universal.JumpToBlock)) } return nil } func validateCustomCommandKey(key string) error { if !isValidKeybindingKey(key) { return fmt.Errorf("Unrecognized key '%s' for custom command. For permitted values see %s", key, constants.Links.Docs.CustomKeybindings) } return nil } func validateCustomCommands(customCommands []CustomCommand) error { for _, customCommand := range customCommands { if err := validateCustomCommandKey(customCommand.Key); err != nil { return err } if len(customCommand.CommandMenu) > 0 && (len(customCommand.Context) > 0 || len(customCommand.Command) > 0 || customCommand.Subprocess != nil || len(customCommand.Prompts) > 0 || len(customCommand.LoadingText) > 0 || customCommand.Stream != nil || customCommand.ShowOutput != nil || len(customCommand.OutputTitle) > 0 || customCommand.After != nil) { commandRef := "" if len(customCommand.Key) > 0 { commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key) } return fmt.Errorf("Error with custom command%s: it is not allowed to use both commandMenu and any of the other fields except key and description.", commandRef) } } return nil } lazygit-0.50.0+ds1/pkg/config/user_config_validation_test.go000066400000000000000000000064121500612110400240640ustar00rootroot00000000000000package config import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestUserConfigValidate_enums(t *testing.T) { type testCase struct { value string valid bool } scenarios := []struct { name string setup func(config *UserConfig, value string) testCases []testCase }{ { name: "Gui.StatusPanelView", setup: func(config *UserConfig, value string) { config.Gui.StatusPanelView = value }, testCases: []testCase{ {value: "dashboard", valid: true}, {value: "allBranchesLog", valid: true}, {value: "", valid: false}, {value: "invalid_value", valid: false}, }, }, { name: "Keybindings", setup: func(config *UserConfig, value string) { config.Keybinding.Universal.Quit = value }, testCases: []testCase{ {value: "", valid: true}, {value: "", valid: true}, {value: "q", valid: true}, {value: "", valid: true}, {value: "invalid_value", valid: false}, }, }, { name: "JumpToBlock keybinding", setup: func(config *UserConfig, value string) { config.Keybinding.Universal.JumpToBlock = strings.Split(value, ",") }, testCases: []testCase{ {value: "", valid: false}, {value: "1,2,3", valid: false}, {value: "1,2,3,4,5", valid: true}, {value: "1,2,3,4,invalid", valid: false}, {value: "1,2,3,4,5,6", valid: false}, }, }, { name: "Custom command keybinding", setup: func(config *UserConfig, value string) { config.CustomCommands = []CustomCommand{ { Key: value, Command: "echo 'hello'", }, } }, testCases: []testCase{ {value: "", valid: true}, {value: "", valid: true}, {value: "q", valid: true}, {value: "", valid: true}, {value: "invalid_value", valid: false}, }, }, { name: "Custom command sub menu", setup: func(config *UserConfig, _ string) { config.CustomCommands = []CustomCommand{ { Key: "X", Description: "My Custom Commands", CommandMenu: []CustomCommand{ {Key: "1", Command: "echo 'hello'", Context: "global"}, }, }, } }, testCases: []testCase{ {value: "", valid: true}, }, }, { name: "Custom command sub menu", setup: func(config *UserConfig, _ string) { config.CustomCommands = []CustomCommand{ { Key: "X", Context: "global", CommandMenu: []CustomCommand{ {Key: "1", Command: "echo 'hello'", Context: "global"}, }, }, } }, testCases: []testCase{ {value: "", valid: false}, }, }, { name: "Custom command sub menu", setup: func(config *UserConfig, _ string) { falseVal := false config.CustomCommands = []CustomCommand{ { Key: "X", Subprocess: &falseVal, CommandMenu: []CustomCommand{ {Key: "1", Command: "echo 'hello'", Context: "global"}, }, }, } }, testCases: []testCase{ {value: "", valid: false}, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { for _, testCase := range s.testCases { config := GetDefaultConfig() s.setup(config, testCase.value) err := config.Validate() if testCase.valid { assert.NoError(t, err) } else { assert.Error(t, err) } } }) } } lazygit-0.50.0+ds1/pkg/constants/000077500000000000000000000000001500612110400165255ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/constants/links.go000066400000000000000000000026621500612110400202020ustar00rootroot00000000000000package constants type Docs struct { CustomPagers string CustomCommands string CustomKeybindings string Keybindings string Undoing string Config string Tutorial string CustomPatchDemo string } var Links = struct { Docs Docs Issues string Donate string Discussions string RepoUrl string Releases string }{ RepoUrl: "https://github.com/jesseduffield/lazygit", Issues: "https://github.com/jesseduffield/lazygit/issues", Donate: "https://github.com/sponsors/jesseduffield", Discussions: "https://github.com/jesseduffield/lazygit/discussions", Releases: "https://github.com/jesseduffield/lazygit/releases", Docs: Docs{ CustomPagers: "https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md", CustomKeybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md", CustomCommands: "https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium", Keybindings: "https://github.com/jesseduffield/lazygit/blob/%s/docs/keybindings", Undoing: "https://github.com/jesseduffield/lazygit/blob/master/docs/Undoing.md", Config: "https://github.com/jesseduffield/lazygit/blob/%s/docs/Config.md", Tutorial: "https://youtu.be/VDXvbHZYeKY", CustomPatchDemo: "https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches", }, } lazygit-0.50.0+ds1/pkg/env/000077500000000000000000000000001500612110400153015ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/env/env.go000066400000000000000000000007271500612110400164260ustar00rootroot00000000000000package env import ( "os" ) // This package encapsulates accessing/mutating the ENV of the program. func GetGitDirEnv() string { return os.Getenv("GIT_DIR") } func SetGitDirEnv(value string) { os.Setenv("GIT_DIR", value) } func GetWorkTreeEnv() string { return os.Getenv("GIT_WORK_TREE") } func SetWorkTreeEnv(value string) { os.Setenv("GIT_WORK_TREE", value) } func UnsetGitLocationEnvVars() { _ = os.Unsetenv("GIT_DIR") _ = os.Unsetenv("GIT_WORK_TREE") } lazygit-0.50.0+ds1/pkg/fakes/000077500000000000000000000000001500612110400156025ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/fakes/log.go000066400000000000000000000016661500612110400167230ustar00rootroot00000000000000package fakes import ( "fmt" "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) var _ logrus.FieldLogger = &FakeFieldLogger{} // for now we're just tracking calls to the Error and Errorf methods type FakeFieldLogger struct { loggedErrors []string *logrus.Entry } func (self *FakeFieldLogger) Error(args ...interface{}) { if len(args) != 1 { panic("Expected exactly one argument to FakeFieldLogger.Error") } switch arg := args[0].(type) { case error: self.loggedErrors = append(self.loggedErrors, arg.Error()) case string: self.loggedErrors = append(self.loggedErrors, arg) } } func (self *FakeFieldLogger) Errorf(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) self.loggedErrors = append(self.loggedErrors, msg) } func (self *FakeFieldLogger) AssertErrors(t *testing.T, expectedErrors []string) { t.Helper() assert.EqualValues(t, expectedErrors, self.loggedErrors) } lazygit-0.50.0+ds1/pkg/gui/000077500000000000000000000000001500612110400152755ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/background.go000066400000000000000000000072361500612110400177530ustar00rootroot00000000000000package gui import ( "fmt" "runtime" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type BackgroundRoutineMgr struct { gui *Gui // if we've suspended the gui (e.g. because we've switched to a subprocess) // we typically want to pause some things that are running like background // file refreshes pauseBackgroundRefreshes bool } func (self *BackgroundRoutineMgr) PauseBackgroundRefreshes(pause bool) { self.pauseBackgroundRefreshes = pause } func (self *BackgroundRoutineMgr) startBackgroundRoutines() { userConfig := self.gui.UserConfig() if userConfig.Git.AutoFetch { fetchInterval := userConfig.Refresher.FetchInterval if fetchInterval > 0 { go utils.Safe(self.startBackgroundFetch) } else { self.gui.c.Log.Errorf( "Value of config option 'refresher.fetchInterval' (%d) is invalid, disabling auto-fetch", fetchInterval) } } if userConfig.Git.AutoRefresh { refreshInterval := userConfig.Refresher.RefreshInterval if refreshInterval > 0 { go utils.Safe(func() { self.startBackgroundFilesRefresh(refreshInterval) }) } else { self.gui.c.Log.Errorf( "Value of config option 'refresher.refreshInterval' (%d) is invalid, disabling auto-refresh", refreshInterval) } } if self.gui.Config.GetDebug() { self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func() error { formatBytes := func(b uint64) string { const unit = 1000 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := uint64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp]) } m := runtime.MemStats{} runtime.ReadMemStats(&m) self.gui.c.Log.Infof("Heap memory in use: %s", formatBytes(m.HeapAlloc)) return nil }) } } func (self *BackgroundRoutineMgr) startBackgroundFetch() { self.gui.waitForIntro.Wait() fetch := func() error { return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { return self.backgroundFetch() }, nil) } // We want an immediate fetch at startup, and since goEvery starts by // waiting for the interval, we need to trigger one manually first _ = fetch() userConfig := self.gui.UserConfig() self.goEvery(time.Second*time.Duration(userConfig.Refresher.FetchInterval), self.gui.stopChan, fetch) } func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh(refreshInterval int) { self.gui.waitForIntro.Wait() self.goEvery(time.Second*time.Duration(refreshInterval), self.gui.stopChan, func() error { return self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) }) } func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func() error) { done := make(chan struct{}) go utils.Safe(func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: if self.pauseBackgroundRefreshes { continue } self.gui.c.OnWorker(func(gocui.Task) error { _ = function() done <- struct{}{} return nil }) // waiting so that we don't bunch up refreshes if the refresh takes longer than the interval <-done case <-stop: return } } }) } func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { err = self.gui.git.Sync.FetchBackground() _ = self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.SYNC}) if err == nil { err = self.gui.helpers.BranchesHelper.AutoForwardBranches() } return err } lazygit-0.50.0+ds1/pkg/gui/command_log_panel.go000066400000000000000000000162531500612110400212710ustar00rootroot00000000000000package gui import ( "fmt" "math/rand" "strings" "time" "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" ) // our UI command log looks like this: // Stage File: // git add -- 'filename' // Unstage File: // git reset HEAD 'filename' // // The 'Stage File' and 'Unstage File' lines are actions i.e they group up a set // of command logs (typically there's only one command under an action but there may be more). // So we call logAction to log the 'Stage File' part and then we call logCommand to log the command itself. // We pass logCommand to our OSCommand struct so that it can handle logging commands // for us. func (gui *Gui) LogAction(action string) { if gui.Views.Extras == nil { return } gui.Views.Extras.Autoscroll = true gui.GuiLog = append(gui.GuiLog, action) fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action)) } func (gui *Gui) LogCommand(cmdStr string, commandLine bool) { if gui.Views.Extras == nil { return } gui.Views.Extras.Autoscroll = true textStyle := theme.DefaultTextColor if !commandLine { // if we're not dealing with a direct command that could be run on the command line, // we style it differently to communicate that textStyle = style.FgMagenta } gui.GuiLog = append(gui.GuiLog, cmdStr) indentedCmdStr := " " + strings.Replace(cmdStr, "\n", "\n ", -1) fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr)) } func (gui *Gui) printCommandLogHeader() { introStr := fmt.Sprintf( gui.c.Tr.CommandLogHeader, keybindings.Label(gui.c.UserConfig().Keybinding.Universal.ExtrasMenu), ) fmt.Fprintln(gui.Views.Extras, style.FgCyan.Sprint(introStr)) if gui.c.UserConfig().Gui.ShowRandomTip { fmt.Fprintf( gui.Views.Extras, "%s: %s", style.FgYellow.Sprint(gui.c.Tr.RandomTip), style.FgGreen.Sprint(gui.getRandomTip()), ) } } func (gui *Gui) getRandomTip() string { config := gui.c.UserConfig().Keybinding formattedKey := func(key string) string { return keybindings.Label(key) } tips := []string{ // keybindings and lazygit-specific advice fmt.Sprintf( "To force push, press '%s' and then if the push is rejected you will be asked if you want to force push", formattedKey(config.Universal.Push), ), fmt.Sprintf( "To filter commits by path, press '%s'", formattedKey(config.Universal.FilteringMenu), ), fmt.Sprintf( "To start an interactive rebase, press '%s' on a commit. You can always abort the rebase by pressing '%s' and selecting 'abort'", formattedKey(config.Universal.Edit), formattedKey(config.Universal.CreateRebaseOptionsMenu), ), fmt.Sprintf( "In flat file view, merge conflicts are sorted to the top. To switch to flat file view press '%s'", formattedKey(config.Files.ToggleTreeView), ), "If you want to learn Go and can think of ways to improve lazygit, join the team! Click 'Ask Question' and express your interest", fmt.Sprintf( "If you press '%s'/'%s' you can undo/redo your changes. Be wary though, this only applies to branches/commits, so only do this if your worktree is clear.\nDocs: %s", formattedKey(config.Universal.Undo), formattedKey(config.Universal.Redo), constants.Links.Docs.Undoing, ), fmt.Sprintf( "to hard reset onto your current upstream branch, press '%s' in the files panel", formattedKey(config.Commits.ViewResetOptions), ), fmt.Sprintf( "To push a tag, navigate to the tag in the tags tab and press '%s'", formattedKey(config.Branches.PushTag), ), fmt.Sprintf( "You can view the individual files of a stash entry by pressing '%s'", formattedKey(config.Universal.GoInto), ), fmt.Sprintf( "You can diff two commits by pressing '%s' on one commit and then navigating to the other. You can then press '%s' to view the files of the diff", formattedKey(config.Universal.DiffingMenu), formattedKey(config.Universal.GoInto), ), fmt.Sprintf( "press '%s' on a commit to drop it (delete it)", formattedKey(config.Universal.Remove), ), fmt.Sprintf( "If you need to pull out the big guns to resolve merge conflicts, you can press '%s' in the files panel to open 'git mergetool'", formattedKey(config.Files.OpenMergeTool), ), fmt.Sprintf( "To revert a commit, press '%s' on that commit", formattedKey(config.Commits.RevertCommit), ), fmt.Sprintf( "To escape a mode, for example cherry-picking, patch-building, diffing, or filtering mode, you can just spam the '%s' button. Unless of course you have `quitOnTopLevelReturn` enabled in your config", formattedKey(config.Universal.Return), ), fmt.Sprintf( "You can page through the items of a panel using '%s' and '%s'", formattedKey(config.Universal.PrevPage), formattedKey(config.Universal.NextPage), ), fmt.Sprintf( "You can jump to the top/bottom of a panel using '%s (or %s)' and '%s (or %s)'", formattedKey(config.Universal.GotoTop), formattedKey(config.Universal.GotoTopAlt), formattedKey(config.Universal.GotoBottom), formattedKey(config.Universal.GotoBottomAlt), ), fmt.Sprintf( "To collapse/expand a directory, press '%s'", formattedKey(config.Universal.GoInto), ), fmt.Sprintf( "You can append your staged changes to an older commit by pressing '%s' on that commit", formattedKey(config.Commits.AmendToCommit), ), fmt.Sprintf( "You can amend the last commit with your new file changes by pressing '%s' in the files panel", formattedKey(config.Files.AmendLastCommit), ), fmt.Sprintf( "You can now navigate the side panels with '%s' and '%s'", formattedKey(config.Universal.NextBlockAlt2), formattedKey(config.Universal.PrevBlockAlt2), ), "You can use lazygit with a bare repo by passing the --git-dir and --work-tree arguments as you would for the git CLI", // general advice "`git commit` is really just the programmer equivalent of saving your game. Always do it before embarking on an ambitious change!", "Try to separate commits that refactor code from commits that add new functionality: if they're squashed into one commit, it can be hard to spot what's new.", "If you ever want to experiment, it's easy to create a new branch off your current one and go nuts, then delete it afterwards", "Always read through the diff of your changes before assigning somebody to review your code. Better for you to catch any silly mistakes than your colleagues!", "If something goes wrong, you can always checkout a commit from your reflog to return to an earlier state", "The stash is a good place to save snippets of code that you always find yourself adding when debugging.", // links fmt.Sprintf( "If you want a git diff with syntax colouring, check out lazygit's integration with delta:\n%s", constants.Links.Docs.CustomPagers, ), fmt.Sprintf( "You can build your own custom menus and commands to run from within lazygit. For examples see:\n%s", constants.Links.Docs.CustomCommands, ), fmt.Sprintf( "If you ever find a bug, do not hesitate to raise an issue on the repo:\n%s", constants.Links.Issues, ), } rnd := rand.New(rand.NewSource(time.Now().UnixNano())) randomIndex := rnd.Intn(len(tips)) return tips[randomIndex] } lazygit-0.50.0+ds1/pkg/gui/context.go000066400000000000000000000226151500612110400173160ustar00rootroot00000000000000package gui import ( "sync" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // This file is for the management of contexts. There is a context stack such that // for example you might start off in the commits context and then open a menu, putting // you in the menu context. When contexts are activated/deactivated certain things need // to happen like showing/hiding views and rendering content. type ContextMgr struct { ContextStack []types.Context sync.RWMutex gui *Gui allContexts *context.ContextTree } func NewContextMgr( gui *Gui, allContexts *context.ContextTree, ) *ContextMgr { return &ContextMgr{ ContextStack: []types.Context{}, RWMutex: sync.RWMutex{}, gui: gui, allContexts: allContexts, } } // use when you don't want to return to the original context upon // hitting escape: you want to go that context's parent instead. func (self *ContextMgr) Replace(c types.Context) { if !c.IsFocusable() { return } self.Lock() if len(self.ContextStack) == 0 { self.ContextStack = []types.Context{c} } else { // replace the last item with the given item self.ContextStack = append(self.ContextStack[0:len(self.ContextStack)-1], c) } self.Unlock() self.Activate(c, types.OnFocusOpts{}) } func (self *ContextMgr) Push(c types.Context, opts types.OnFocusOpts) { if !c.IsFocusable() { return } contextsToDeactivate, contextToActivate := self.pushToContextStack(c) for _, contextToDeactivate := range contextsToDeactivate { self.deactivate(contextToDeactivate, types.OnFocusLostOpts{NewContextKey: c.GetKey()}) } if contextToActivate != nil { self.Activate(contextToActivate, opts) } } // Adjusts the context stack based on the context that's being pushed and // returns (contexts to deactivate, context to activate) func (self *ContextMgr) pushToContextStack(c types.Context) ([]types.Context, types.Context) { contextsToDeactivate := []types.Context{} self.Lock() defer self.Unlock() if len(self.ContextStack) > 0 && c.GetKey() == self.ContextStack[len(self.ContextStack)-1].GetKey() { // Context being pushed is already on top of the stack: nothing to // deactivate or activate return contextsToDeactivate, nil } if len(self.ContextStack) == 0 { self.ContextStack = append(self.ContextStack, c) } else if c.GetKind() == types.SIDE_CONTEXT { // if we are switching to a side context, remove all other contexts in the stack contextsToDeactivate = lo.Filter(self.ContextStack, func(context types.Context, _ int) bool { return context.GetKey() != c.GetKey() }) self.ContextStack = []types.Context{c} } else if c.GetKind() == types.MAIN_CONTEXT { // if we're switching to a main context, remove all other main contexts in the stack contextsToKeep := []types.Context{} for _, stackContext := range self.ContextStack { if stackContext.GetKind() == types.MAIN_CONTEXT { contextsToDeactivate = append(contextsToDeactivate, stackContext) } else { contextsToKeep = append(contextsToKeep, stackContext) } } self.ContextStack = append(contextsToKeep, c) } else { topContext := self.currentContextWithoutLock() // if we're pushing the same context on, we do nothing. if topContext.GetKey() != c.GetKey() { // if top one is a temporary popup, we remove it. Ideally you'd be able to // escape back to previous temporary popups, but because we're currently reusing // views for this, you might not be able to get back to where you previously were. // The exception is when going to the search context e.g. for searching a menu. if (topContext.GetKind() == types.TEMPORARY_POPUP && c.GetKey() != context.SEARCH_CONTEXT_KEY) || // we only ever want one main context on the stack at a time. (topContext.GetKind() == types.MAIN_CONTEXT && c.GetKind() == types.MAIN_CONTEXT) { contextsToDeactivate = append(contextsToDeactivate, topContext) _, self.ContextStack = utils.Pop(self.ContextStack) } self.ContextStack = append(self.ContextStack, c) } } return contextsToDeactivate, c } func (self *ContextMgr) Pop() { self.Lock() if len(self.ContextStack) == 1 { // cannot escape from bottommost context self.Unlock() return } var currentContext types.Context currentContext, self.ContextStack = utils.Pop(self.ContextStack) newContext := self.ContextStack[len(self.ContextStack)-1] self.Unlock() self.deactivate(currentContext, types.OnFocusLostOpts{NewContextKey: newContext.GetKey()}) self.Activate(newContext, types.OnFocusOpts{}) } func (self *ContextMgr) deactivate(c types.Context, opts types.OnFocusLostOpts) { view, _ := self.gui.c.GocuiGui().View(c.GetViewName()) if opts.NewContextKey != context.SEARCH_CONTEXT_KEY { if c.GetKind() == types.MAIN_CONTEXT || c.GetKind() == types.TEMPORARY_POPUP { self.gui.helpers.Search.CancelSearchIfSearching(c) } } // if we are the kind of context that is sent to back upon deactivation, we should do that if view != nil && (c.GetKind() == types.TEMPORARY_POPUP || c.GetKind() == types.PERSISTENT_POPUP) { view.Visible = false } c.HandleFocusLost(opts) } func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) { viewName := c.GetViewName() v, err := self.gui.c.GocuiGui().View(viewName) if err != nil { panic(err) } self.gui.helpers.Window.SetWindowContext(c) self.gui.helpers.Window.MoveToTopOfWindow(c) oldView := self.gui.c.GocuiGui().CurrentView() if oldView != nil && oldView.Name() != viewName { oldView.HighlightInactive = true } if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil { panic(err) } self.gui.helpers.Search.RenderSearchStatus(c) desiredTitle := c.Title() if desiredTitle != "" { v.Title = desiredTitle } v.Visible = true self.gui.c.GocuiGui().Cursor = v.Editable c.HandleFocus(opts) } func (self *ContextMgr) Current() types.Context { self.RLock() defer self.RUnlock() return self.currentContextWithoutLock() } func (self *ContextMgr) currentContextWithoutLock() types.Context { if len(self.ContextStack) == 0 { return self.gui.defaultSideContext() } return self.ContextStack[len(self.ContextStack)-1] } // Note that this could return the 'status' context which is not itself a list context. func (self *ContextMgr) CurrentSide() types.Context { self.RLock() defer self.RUnlock() stack := self.ContextStack // find the first context in the stack with the type of types.SIDE_CONTEXT for i := range stack { context := stack[len(stack)-1-i] if context.GetKind() == types.SIDE_CONTEXT { return context } } return self.gui.defaultSideContext() } // static as opposed to popup func (self *ContextMgr) CurrentStatic() types.Context { self.RLock() defer self.RUnlock() return self.currentStaticContextWithoutLock() } func (self *ContextMgr) currentStaticContextWithoutLock() types.Context { stack := self.ContextStack if len(stack) == 0 { return self.gui.defaultSideContext() } // find the first context in the stack without a popup type for i := range stack { context := stack[len(stack)-1-i] if context.GetKind() != types.TEMPORARY_POPUP && context.GetKind() != types.PERSISTENT_POPUP { return context } } return self.gui.defaultSideContext() } func (self *ContextMgr) ForEach(f func(types.Context)) { self.RLock() defer self.RUnlock() for _, context := range self.gui.State.ContextMgr.ContextStack { f(context) } } func (self *ContextMgr) IsCurrent(c types.Context) bool { return self.Current().GetKey() == c.GetKey() } func (self *ContextMgr) IsCurrentOrParent(c types.Context) bool { current := self.Current() for current != nil { if current.GetKey() == c.GetKey() { return true } current = current.GetParentContext() } return false } func (self *ContextMgr) AllFilterable() []types.IFilterableContext { var result []types.IFilterableContext for _, context := range self.allContexts.Flatten() { if ctx, ok := context.(types.IFilterableContext); ok { result = append(result, ctx) } } return result } func (self *ContextMgr) AllSearchable() []types.ISearchableContext { var result []types.ISearchableContext for _, context := range self.allContexts.Flatten() { if ctx, ok := context.(types.ISearchableContext); ok { result = append(result, ctx) } } return result } // all list contexts func (self *ContextMgr) AllList() []types.IListContext { var listContexts []types.IListContext for _, context := range self.allContexts.Flatten() { if listContext, ok := context.(types.IListContext); ok { listContexts = append(listContexts, listContext) } } return listContexts } func (self *ContextMgr) AllPatchExplorer() []types.IPatchExplorerContext { var listContexts []types.IPatchExplorerContext for _, context := range self.allContexts.Flatten() { if listContext, ok := context.(types.IPatchExplorerContext); ok { listContexts = append(listContexts, listContext) } } return listContexts } func (self *ContextMgr) ContextForKey(key types.ContextKey) types.Context { self.RLock() defer self.RUnlock() for _, context := range self.allContexts.Flatten() { if context.GetKey() == key { return context } } return nil } func (self *ContextMgr) CurrentPopup() []types.Context { self.RLock() defer self.RUnlock() return lo.Filter(self.ContextStack, func(context types.Context, _ int) bool { return context.GetKind() == types.TEMPORARY_POPUP || context.GetKind() == types.PERSISTENT_POPUP }) } lazygit-0.50.0+ds1/pkg/gui/context/000077500000000000000000000000001500612110400167615ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/context/base_context.go000066400000000000000000000141331500612110400217700ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type BaseContext struct { kind types.ContextKind key types.ContextKey view *gocui.View viewTrait types.IViewTrait windowName string onGetOptionsMap func() map[string]string keybindingsFns []types.KeybindingsFn mouseKeybindingsFns []types.MouseKeybindingsFn onClickFn func() error onClickFocusedMainViewFn onClickFocusedMainViewFn onRenderToMainFn func() onFocusFn onFocusFn onFocusLostFn onFocusLostFn focusable bool transient bool hasControlledBounds bool needsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel needsRerenderOnHeightChange bool highlightOnFocus bool *ParentContextMgr } type ( onFocusFn = func(types.OnFocusOpts) onFocusLostFn = func(types.OnFocusLostOpts) onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error ) var _ types.IBaseContext = &BaseContext{} type NewBaseContextOpts struct { Kind types.ContextKind Key types.ContextKey View *gocui.View WindowName string Focusable bool Transient bool HasUncontrolledBounds bool // negating for the sake of making false the default HighlightOnFocus bool NeedsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel NeedsRerenderOnHeightChange bool OnGetOptionsMap func() map[string]string } func NewBaseContext(opts NewBaseContextOpts) *BaseContext { viewTrait := NewViewTrait(opts.View) hasControlledBounds := !opts.HasUncontrolledBounds return &BaseContext{ kind: opts.Kind, key: opts.Key, view: opts.View, windowName: opts.WindowName, onGetOptionsMap: opts.OnGetOptionsMap, focusable: opts.Focusable, transient: opts.Transient, hasControlledBounds: hasControlledBounds, highlightOnFocus: opts.HighlightOnFocus, needsRerenderOnWidthChange: opts.NeedsRerenderOnWidthChange, needsRerenderOnHeightChange: opts.NeedsRerenderOnHeightChange, ParentContextMgr: &ParentContextMgr{}, viewTrait: viewTrait, } } func (self *BaseContext) GetOptionsMap() map[string]string { if self.onGetOptionsMap != nil { return self.onGetOptionsMap() } return nil } func (self *BaseContext) SetWindowName(windowName string) { self.windowName = windowName } func (self *BaseContext) GetWindowName() string { return self.windowName } func (self *BaseContext) GetViewName() string { // for the sake of the global context which has no view if self.view == nil { return "" } return self.view.Name() } func (self *BaseContext) GetView() *gocui.View { return self.view } func (self *BaseContext) GetViewTrait() types.IViewTrait { return self.viewTrait } func (self *BaseContext) GetKind() types.ContextKind { return self.kind } func (self *BaseContext) GetKey() types.ContextKey { return self.key } func (self *BaseContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{} for i := range self.keybindingsFns { // the first binding in the bindings array takes precedence but we want the // last keybindingsFn to take precedence to we add them in reverse bindings = append(bindings, self.keybindingsFns[len(self.keybindingsFns)-1-i](opts)...) } return bindings } func (self *BaseContext) AddKeybindingsFn(fn types.KeybindingsFn) { self.keybindingsFns = append(self.keybindingsFns, fn) } func (self *BaseContext) AddMouseKeybindingsFn(fn types.MouseKeybindingsFn) { self.mouseKeybindingsFns = append(self.mouseKeybindingsFns, fn) } func (self *BaseContext) ClearAllBindingsFn() { self.keybindingsFns = []types.KeybindingsFn{} self.mouseKeybindingsFns = []types.MouseKeybindingsFn{} } func (self *BaseContext) AddOnClickFn(fn func() error) { if fn != nil { self.onClickFn = fn } } func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) { if fn != nil { self.onClickFocusedMainViewFn = fn } } func (self *BaseContext) GetOnClick() func() error { return self.onClickFn } func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn { return self.onClickFocusedMainViewFn } func (self *BaseContext) AddOnRenderToMainFn(fn func()) { if fn != nil { self.onRenderToMainFn = fn } } func (self *BaseContext) GetOnRenderToMain() func() { return self.onRenderToMainFn } func (self *BaseContext) AddOnFocusFn(fn onFocusFn) { if fn != nil { self.onFocusFn = fn } } func (self *BaseContext) GetOnFocus() onFocusFn { return self.onFocusFn } func (self *BaseContext) AddOnFocusLostFn(fn onFocusLostFn) { if fn != nil { self.onFocusLostFn = fn } } func (self *BaseContext) GetOnFocusLost() onFocusLostFn { return self.onFocusLostFn } func (self *BaseContext) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { bindings := []*gocui.ViewMouseBinding{} for i := range self.mouseKeybindingsFns { // the first binding in the bindings array takes precedence but we want the // last keybindingsFn to take precedence to we add them in reverse bindings = append(bindings, self.mouseKeybindingsFns[len(self.mouseKeybindingsFns)-1-i](opts)...) } return bindings } func (self *BaseContext) IsFocusable() bool { return self.focusable } func (self *BaseContext) IsTransient() bool { return self.transient } func (self *BaseContext) HasControlledBounds() bool { return self.hasControlledBounds } func (self *BaseContext) NeedsRerenderOnWidthChange() types.NeedsRerenderOnWidthChangeLevel { return self.needsRerenderOnWidthChange } func (self *BaseContext) NeedsRerenderOnHeightChange() bool { return self.needsRerenderOnHeightChange } func (self *BaseContext) Title() string { return "" } func (self *BaseContext) TotalContentHeight() int { return self.view.ViewLinesHeight() } lazygit-0.50.0+ds1/pkg/gui/context/branches_context.go000066400000000000000000000045261500612110400226500ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type BranchesContext struct { *FilteredListViewModel[*models.Branch] *ListContextTrait } var ( _ types.IListContext = (*BranchesContext)(nil) _ types.DiffableContext = (*BranchesContext)(nil) ) func NewBranchesContext(c *ContextCommon) *BranchesContext { viewModel := NewFilteredListViewModel( func() []*models.Branch { return c.Model().Branches }, func(branch *models.Branch) []string { return []string{branch.Name} }, ) getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetBranchListDisplayStrings( viewModel.GetItems(), c.State().GetItemOperation, c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.Modes().Diffing.Ref, c.Views().Branches.InnerWidth()+c.Views().Branches.OriginX(), c.Tr, c.UserConfig(), c.Model().Worktrees, ) } self := &BranchesContext{ FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Branches, WindowName: "branches", Key: LOCAL_BRANCHES_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } return self } func (self *BranchesContext) GetSelectedRef() types.Ref { branch := self.GetSelected() if branch == nil { return nil } return branch } func (self *BranchesContext) GetDiffTerminals() []string { // for our local branches we want to include both the branch and its upstream branch := self.GetSelected() if branch != nil { names := []string{branch.ID()} if branch.IsTrackingRemote() { names = append(names, branch.ID()+"@{u}") } return names } return nil } func (self *BranchesContext) RefForAdjustingLineNumberInDiff() string { branch := self.GetSelected() if branch != nil { return branch.ID() } return "" } func (self *BranchesContext) ShowBranchHeadsInSubCommits() bool { return true } lazygit-0.50.0+ds1/pkg/gui/context/commit_files_context.go000066400000000000000000000061201500612110400235250ustar00rootroot00000000000000package context import ( "fmt" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type CommitFilesContext struct { *filetree.CommitFileTreeViewModel *ListContextTrait *DynamicTitleBuilder *SearchTrait } var ( _ types.IListContext = (*CommitFilesContext)(nil) _ types.DiffableContext = (*CommitFilesContext)(nil) _ types.ISearchableContext = (*CommitFilesContext)(nil) ) func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext { viewModel := filetree.NewCommitFileTreeViewModel( func() []*models.CommitFile { return c.Model().CommitFiles }, c.Log, c.UserConfig().Gui.ShowFileTree, ) getDisplayStrings := func(_ int, _ int) [][]string { if viewModel.Len() == 0 { return [][]string{{style.FgRed.Sprint("(none)")}} } showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons lines := presentation.RenderCommitFileTree(viewModel, c.Git().Patch.PatchBuilder, showFileIcons, &c.UserConfig().Gui.CustomIcons) return lo.Map(lines, func(line string, _ int) []string { return []string{line} }) } ctx := &CommitFilesContext{ CommitFileTreeViewModel: viewModel, DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.CommitFilesDynamicTitle), SearchTrait: NewSearchTrait(c), ListContextTrait: &ListContextTrait{ Context: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ View: c.Views().CommitFiles, WindowName: "commits", Key: COMMIT_FILES_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, Transient: true, }), ), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect)) return ctx } func (self *CommitFilesContext) GetDiffTerminals() []string { return []string{self.GetRef().RefName()} } func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string { if refs := self.GetRefRange(); refs != nil { return refs.To.RefName() } return self.GetRef().RefName() } func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) { if refs := self.GetRefRange(); refs != nil { return refs.From.ParentRefName(), refs.To.RefName() } ref := self.GetRef() return ref.ParentRefName(), ref.RefName() } func (self *CommitFilesContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { return nil } func (self *CommitFilesContext) ReInit(ref types.Ref, refRange *types.RefRange) { self.SetRef(ref) self.SetRefRange(refRange) if refRange != nil { self.SetTitleRef(fmt.Sprintf("%s-%s", refRange.From.ShortRefName(), refRange.To.ShortRefName())) } else { self.SetTitleRef(ref.Description()) } self.GetView().Title = self.Title() } lazygit-0.50.0+ds1/pkg/gui/context/commit_message_context.go000066400000000000000000000143771500612110400240640ustar00rootroot00000000000000package context import ( "os" "path/filepath" "strconv" "strings" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/spf13/afero" ) const PreservedCommitMessageFileName = "LAZYGIT_PENDING_COMMIT" type CommitMessageContext struct { c *ContextCommon types.Context viewModel *CommitMessageViewModel } var _ types.Context = (*CommitMessageContext)(nil) // when selectedIndex (see below) is set to this value, it means that we're not // currently viewing a commit message of an existing commit: instead we're making our own // new commit message const NoCommitIndex = -1 type CommitMessageViewModel struct { // index of the commit message, where -1 is 'no commit', 0 is the HEAD commit, 1 // is the prior commit, and so on selectedindex int // if true, then upon escaping from the commit message panel, we will preserve // the message so that it's still shown next time we open the panel preserveMessage bool // we remember the initial message so that we can tell whether we should preserve // the message; if it's still identical to the initial message, we don't initialMessage string // invoked when pressing enter in the commit message panel onConfirm func(string, string) error // invoked when pressing the switch-to-editor key binding onSwitchToEditor func(string) error // the following two fields are used for the display of the "hooks disabled" subtitle forceSkipHooks bool skipHooksPrefix string // The message typed in before cycling through history // We store this separately to 'preservedMessage' because 'preservedMessage' // is specifically for committing staged files and we don't want this affected // by cycling through history in the context of rewording an old commit. historyMessage string } func NewCommitMessageContext( c *ContextCommon, ) *CommitMessageContext { viewModel := &CommitMessageViewModel{} return &CommitMessageContext{ c: c, viewModel: viewModel, Context: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.PERSISTENT_POPUP, View: c.Views().CommitMessage, WindowName: "commitMessage", Key: COMMIT_MESSAGE_CONTEXT_KEY, Focusable: true, HasUncontrolledBounds: true, }), ), } } func (self *CommitMessageContext) SetSelectedIndex(value int) { self.viewModel.selectedindex = value } func (self *CommitMessageContext) GetSelectedIndex() int { return self.viewModel.selectedindex } func (self *CommitMessageContext) GetPreservedMessagePath() string { return filepath.Join(self.c.Git().RepoPaths.WorktreeGitDirPath(), PreservedCommitMessageFileName) } func (self *CommitMessageContext) GetPreserveMessage() bool { return self.viewModel.preserveMessage } func (self *CommitMessageContext) getPreservedMessage() (string, error) { buf, err := afero.ReadFile(self.c.Fs, self.GetPreservedMessagePath()) if os.IsNotExist(err) { return "", nil } if err != nil { return "", err } return string(buf), nil } func (self *CommitMessageContext) GetPreservedMessageAndLogError() string { msg, err := self.getPreservedMessage() if err != nil { self.c.Log.Errorf("error when retrieving persisted commit message: %v", err) } return msg } func (self *CommitMessageContext) setPreservedMessage(message string) error { preservedFilePath := self.GetPreservedMessagePath() if len(message) == 0 { err := self.c.Fs.Remove(preservedFilePath) if os.IsNotExist(err) { return nil } return err } return afero.WriteFile(self.c.Fs, preservedFilePath, []byte(message), 0o644) } func (self *CommitMessageContext) SetPreservedMessageAndLogError(message string) { if err := self.setPreservedMessage(message); err != nil { self.c.Log.Errorf("error when persisting commit message: %v", err) } } func (self *CommitMessageContext) GetInitialMessage() string { return strings.TrimSpace(self.viewModel.initialMessage) } func (self *CommitMessageContext) GetHistoryMessage() string { return self.viewModel.historyMessage } func (self *CommitMessageContext) SetHistoryMessage(message string) { self.viewModel.historyMessage = message } func (self *CommitMessageContext) OnConfirm(summary string, description string) error { return self.viewModel.onConfirm(summary, description) } func (self *CommitMessageContext) SetPanelState( index int, summaryTitle string, descriptionTitle string, preserveMessage bool, initialMessage string, onConfirm func(string, string) error, onSwitchToEditor func(string) error, forceSkipHooks bool, skipHooksPrefix string, ) { self.viewModel.selectedindex = index self.viewModel.preserveMessage = preserveMessage self.viewModel.initialMessage = initialMessage self.viewModel.onConfirm = onConfirm self.viewModel.onSwitchToEditor = onSwitchToEditor self.viewModel.forceSkipHooks = forceSkipHooks self.viewModel.skipHooksPrefix = skipHooksPrefix self.GetView().Title = summaryTitle self.c.Views().CommitDescription.Title = descriptionTitle self.c.Views().CommitDescription.Subtitle = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionSubTitle, map[string]string{ "togglePanelKeyBinding": keybindings.Label(self.c.UserConfig().Keybinding.Universal.TogglePanel), "commitMenuKeybinding": keybindings.Label(self.c.UserConfig().Keybinding.CommitMessage.CommitMenu), }) self.c.Views().CommitDescription.Visible = true } func (self *CommitMessageContext) RenderSubtitle() { skipHookPrefix := self.viewModel.skipHooksPrefix subject := self.c.Views().CommitMessage.TextArea.GetContent() var subtitle string if self.viewModel.forceSkipHooks || (skipHookPrefix != "" && strings.HasPrefix(subject, skipHookPrefix)) { subtitle = self.c.Tr.CommitHooksDisabledSubTitle } if self.c.UserConfig().Gui.CommitLength.Show { if subtitle != "" { subtitle += "─" } subtitle += getBufferLength(subject) } self.c.Views().CommitMessage.Subtitle = subtitle } func getBufferLength(subject string) string { return " " + strconv.Itoa(strings.Count(subject, "")-1) + " " } func (self *CommitMessageContext) SwitchToEditor(message string) error { return self.viewModel.onSwitchToEditor(message) } func (self *CommitMessageContext) CanSwitchToEditor() bool { return self.viewModel.onSwitchToEditor != nil } lazygit-0.50.0+ds1/pkg/gui/context/confirmation_context.go000066400000000000000000000014051500612110400235440ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ConfirmationContext struct { *SimpleContext c *ContextCommon State ConfirmationContextState } type ConfirmationContextState struct { OnConfirm func() error OnClose func() error } var _ types.Context = (*ConfirmationContext)(nil) func NewConfirmationContext( c *ContextCommon, ) *ConfirmationContext { return &ConfirmationContext{ c: c, SimpleContext: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Confirmation, WindowName: "confirmation", Key: CONFIRMATION_CONTEXT_KEY, Kind: types.TEMPORARY_POPUP, Focusable: true, HasUncontrolledBounds: true, })), } } lazygit-0.50.0+ds1/pkg/gui/context/context.go000066400000000000000000000134761500612110400210070ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/gui/types" ) const ( // used as a nil value when passing a context key as an arg NO_CONTEXT types.ContextKey = "none" GLOBAL_CONTEXT_KEY types.ContextKey = "global" STATUS_CONTEXT_KEY types.ContextKey = "status" SNAKE_CONTEXT_KEY types.ContextKey = "snake" FILES_CONTEXT_KEY types.ContextKey = "files" LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches" REMOTES_CONTEXT_KEY types.ContextKey = "remotes" WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees" REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches" TAGS_CONTEXT_KEY types.ContextKey = "tags" LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits" REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits" SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits" COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles" STASH_CONTEXT_KEY types.ContextKey = "stash" NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal" NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary" STAGING_MAIN_CONTEXT_KEY types.ContextKey = "staging" STAGING_SECONDARY_CONTEXT_KEY types.ContextKey = "stagingSecondary" PATCH_BUILDING_MAIN_CONTEXT_KEY types.ContextKey = "patchBuilding" PATCH_BUILDING_SECONDARY_CONTEXT_KEY types.ContextKey = "patchBuildingSecondary" MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts" // these shouldn't really be needed for anything but I'm giving them unique keys nonetheless OPTIONS_CONTEXT_KEY types.ContextKey = "options" APP_STATUS_CONTEXT_KEY types.ContextKey = "appStatus" SEARCH_PREFIX_CONTEXT_KEY types.ContextKey = "searchPrefix" INFORMATION_CONTEXT_KEY types.ContextKey = "information" LIMIT_CONTEXT_KEY types.ContextKey = "limit" STATUS_SPACER1_CONTEXT_KEY types.ContextKey = "statusSpacer1" STATUS_SPACER2_CONTEXT_KEY types.ContextKey = "statusSpacer2" MENU_CONTEXT_KEY types.ContextKey = "menu" CONFIRMATION_CONTEXT_KEY types.ContextKey = "confirmation" SEARCH_CONTEXT_KEY types.ContextKey = "search" COMMIT_MESSAGE_CONTEXT_KEY types.ContextKey = "commitMessage" COMMIT_DESCRIPTION_CONTEXT_KEY types.ContextKey = "commitDescription" SUBMODULES_CONTEXT_KEY types.ContextKey = "submodules" SUGGESTIONS_CONTEXT_KEY types.ContextKey = "suggestions" COMMAND_LOG_CONTEXT_KEY types.ContextKey = "cmdLog" ) var AllContextKeys = []types.ContextKey{ GLOBAL_CONTEXT_KEY, STATUS_CONTEXT_KEY, FILES_CONTEXT_KEY, LOCAL_BRANCHES_CONTEXT_KEY, REMOTES_CONTEXT_KEY, WORKTREES_CONTEXT_KEY, REMOTE_BRANCHES_CONTEXT_KEY, TAGS_CONTEXT_KEY, LOCAL_COMMITS_CONTEXT_KEY, REFLOG_COMMITS_CONTEXT_KEY, SUB_COMMITS_CONTEXT_KEY, COMMIT_FILES_CONTEXT_KEY, STASH_CONTEXT_KEY, NORMAL_MAIN_CONTEXT_KEY, NORMAL_SECONDARY_CONTEXT_KEY, STAGING_MAIN_CONTEXT_KEY, STAGING_SECONDARY_CONTEXT_KEY, PATCH_BUILDING_MAIN_CONTEXT_KEY, PATCH_BUILDING_SECONDARY_CONTEXT_KEY, MERGE_CONFLICTS_CONTEXT_KEY, MENU_CONTEXT_KEY, CONFIRMATION_CONTEXT_KEY, SEARCH_CONTEXT_KEY, COMMIT_MESSAGE_CONTEXT_KEY, SUBMODULES_CONTEXT_KEY, SUGGESTIONS_CONTEXT_KEY, COMMAND_LOG_CONTEXT_KEY, } type ContextTree struct { Global types.Context Status types.Context Snake types.Context Files *WorkingTreeContext Menu *MenuContext Branches *BranchesContext Tags *TagsContext LocalCommits *LocalCommitsContext CommitFiles *CommitFilesContext Remotes *RemotesContext Worktrees *WorktreesContext Submodules *SubmodulesContext RemoteBranches *RemoteBranchesContext ReflogCommits *ReflogCommitsContext SubCommits *SubCommitsContext Stash *StashContext Suggestions *SuggestionsContext Normal *MainContext NormalSecondary *MainContext Staging *PatchExplorerContext StagingSecondary *PatchExplorerContext CustomPatchBuilder *PatchExplorerContext CustomPatchBuilderSecondary types.Context MergeConflicts *MergeConflictsContext Confirmation *ConfirmationContext CommitMessage *CommitMessageContext CommitDescription types.Context CommandLog types.Context // display contexts AppStatus types.Context Options types.Context SearchPrefix types.Context Search types.Context Information types.Context Limit types.Context StatusSpacer1 types.Context StatusSpacer2 types.Context } // the order of this decides which context is initially at the top of its window func (self *ContextTree) Flatten() []types.Context { return []types.Context{ self.Global, self.Status, self.Snake, self.Submodules, self.Worktrees, self.Files, self.SubCommits, self.Remotes, self.RemoteBranches, self.Tags, self.Branches, self.CommitFiles, self.ReflogCommits, self.LocalCommits, self.Stash, self.Menu, self.Confirmation, self.CommitMessage, self.CommitDescription, self.MergeConflicts, self.StagingSecondary, self.Staging, self.CustomPatchBuilderSecondary, self.CustomPatchBuilder, self.NormalSecondary, self.Normal, self.Suggestions, self.CommandLog, self.AppStatus, self.Options, self.SearchPrefix, self.Search, self.Information, self.Limit, self.StatusSpacer1, self.StatusSpacer2, } } type TabView struct { Tab string ViewName string } lazygit-0.50.0+ds1/pkg/gui/context/context_common.go000066400000000000000000000002761500612110400223510ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ContextCommon struct { *common.Common types.IGuiCommon } lazygit-0.50.0+ds1/pkg/gui/context/dynamic_title_builder.go000066400000000000000000000007371500612110400236520ustar00rootroot00000000000000package context import "fmt" type DynamicTitleBuilder struct { formatStr string // e.g. 'remote branches for %s' titleRef string // e.g. 'origin' } func NewDynamicTitleBuilder(formatStr string) *DynamicTitleBuilder { return &DynamicTitleBuilder{ formatStr: formatStr, } } func (self *DynamicTitleBuilder) SetTitleRef(titleRef string) { self.titleRef = titleRef } func (self *DynamicTitleBuilder) Title() string { return fmt.Sprintf(self.formatStr, self.titleRef) } lazygit-0.50.0+ds1/pkg/gui/context/filtered_list.go000066400000000000000000000045721500612110400221510ustar00rootroot00000000000000package context import ( "strings" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sahilm/fuzzy" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" ) type FilteredList[T any] struct { filteredIndices []int // if nil, we are not filtering getList func() []T getFilterFields func(T) []string filter string mutex *deadlock.Mutex } func NewFilteredList[T any](getList func() []T, getFilterFields func(T) []string) *FilteredList[T] { return &FilteredList[T]{ getList: getList, getFilterFields: getFilterFields, mutex: &deadlock.Mutex{}, } } func (self *FilteredList[T]) GetFilter() string { return self.filter } func (self *FilteredList[T]) SetFilter(filter string, useFuzzySearch bool) { self.filter = filter self.applyFilter(useFuzzySearch) } func (self *FilteredList[T]) ClearFilter() { self.SetFilter("", false) } func (self *FilteredList[T]) ReApplyFilter(useFuzzySearch bool) { self.applyFilter(useFuzzySearch) } func (self *FilteredList[T]) IsFiltering() bool { return self.filter != "" } func (self *FilteredList[T]) GetFilteredList() []T { if self.filteredIndices == nil { return self.getList() } return utils.ValuesAtIndices(self.getList(), self.filteredIndices) } // TODO: update to just 'Len' func (self *FilteredList[T]) UnfilteredLen() int { return len(self.getList()) } type fuzzySource[T any] struct { list []T getFilterFields func(T) []string } var _ fuzzy.Source = &fuzzySource[string]{} func (self *fuzzySource[T]) String(i int) string { return strings.Join(self.getFilterFields(self.list[i]), " ") } func (self *fuzzySource[T]) Len() int { return len(self.list) } func (self *FilteredList[T]) applyFilter(useFuzzySearch bool) { self.mutex.Lock() defer self.mutex.Unlock() if self.filter == "" { self.filteredIndices = nil } else { source := &fuzzySource[T]{ list: self.getList(), getFilterFields: self.getFilterFields, } matches := utils.FindFrom(self.filter, source, useFuzzySearch) self.filteredIndices = lo.Map(matches, func(match fuzzy.Match, _ int) int { return match.Index }) } } func (self *FilteredList[T]) UnfilteredIndex(index int) int { self.mutex.Lock() defer self.mutex.Unlock() if self.filteredIndices == nil { return index } // we use -1 when there are no items if index == -1 { return -1 } return self.filteredIndices[index] } lazygit-0.50.0+ds1/pkg/gui/context/filtered_list_view_model.go000066400000000000000000000017321500612110400243560ustar00rootroot00000000000000package context type FilteredListViewModel[T HasID] struct { *FilteredList[T] *ListViewModel[T] *SearchHistory } func NewFilteredListViewModel[T HasID](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] { filteredList := NewFilteredList(getList, getFilterFields) self := &FilteredListViewModel[T]{ FilteredList: filteredList, SearchHistory: NewSearchHistory(), } listViewModel := NewListViewModel(filteredList.GetFilteredList) self.ListViewModel = listViewModel return self } // used for type switch func (self *FilteredListViewModel[T]) IsFilterableContext() {} func (self *FilteredListViewModel[T]) ClearFilter() { // Set the selected line index to the unfiltered index of the currently selected line, // so that the current item is still selected after the filter is cleared. unfilteredIndex := self.FilteredList.UnfilteredIndex(self.GetSelectedLineIdx()) self.FilteredList.ClearFilter() self.SetSelection(unfilteredIndex) } lazygit-0.50.0+ds1/pkg/gui/context/history_trait.go000066400000000000000000000006731500612110400222220ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/utils" ) // Maintains a list of strings that have previously been searched/filtered for type SearchHistory struct { history *utils.HistoryBuffer[string] } func NewSearchHistory() *SearchHistory { return &SearchHistory{ history: utils.NewHistoryBuffer[string](1000), } } func (self *SearchHistory) GetSearchHistory() *utils.HistoryBuffer[string] { return self.history } lazygit-0.50.0+ds1/pkg/gui/context/list_context_trait.go000066400000000000000000000113251500612110400232340ustar00rootroot00000000000000package context import ( "fmt" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ListContextTrait struct { types.Context ListRenderer c *ContextCommon // Some contexts, like the commit context, will highlight the path from the selected commit // to its parents, because it's ambiguous otherwise. For these, we need to refresh the viewport // so that we show the highlighted path. // TODO: now that we allow scrolling, we should be smarter about what gets refreshed: // we should find out exactly which lines are now part of the path and refresh those. // We should also keep track of the previous path and refresh those lines too. refreshViewportOnChange bool // If this is true, we only render the visible lines of the list. Useful for lists that can // get very long, because it can save a lot of memory renderOnlyVisibleLines bool } func (self *ListContextTrait) IsListContext() {} func (self *ListContextTrait) FocusLine() { self.Context.FocusLine() // Doing this at the end of the layout function because we need the view to be // resized before we focus the line, otherwise if we're in accordion mode // the view could be squashed and won't how to adjust the cursor/origin. // Also, refreshing the viewport needs to happen after the view has been resized. self.c.AfterLayout(func() error { oldOrigin, _ := self.GetViewTrait().ViewPortYBounds() self.GetViewTrait().FocusPoint( self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx())) selectRangeIndex, isSelectingRange := self.list.GetRangeStartIdx() if isSelectingRange { selectRangeIndex = self.ModelIndexToViewIndex(selectRangeIndex) self.GetViewTrait().SetRangeSelectStart(selectRangeIndex) } else { self.GetViewTrait().CancelRangeSelect() } if self.refreshViewportOnChange { self.refreshViewport() } else if self.renderOnlyVisibleLines { newOrigin, _ := self.GetViewTrait().ViewPortYBounds() if oldOrigin != newOrigin { self.HandleRender() } } return nil }) self.setFooter() } func (self *ListContextTrait) refreshViewport() { startIdx, length := self.GetViewTrait().ViewPortYBounds() content := self.renderLines(startIdx, startIdx+length) self.GetViewTrait().SetViewPortContent(content) } func (self *ListContextTrait) setFooter() { self.GetViewTrait().SetFooter(formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len())) } func formatListFooter(selectedLineIdx int, length int) string { return fmt.Sprintf("%d of %d", selectedLineIdx+1, length) } func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) { self.FocusLine() self.GetViewTrait().SetHighlight(self.list.Len() > 0) self.Context.HandleFocus(opts) } func (self *ListContextTrait) HandleFocusLost(opts types.OnFocusLostOpts) { self.GetViewTrait().SetOriginX(0) if self.refreshViewportOnChange { self.refreshViewport() } self.Context.HandleFocusLost(opts) } // OnFocus assumes that the content of the context has already been rendered to the view. OnRender is the function which actually renders the content to the view func (self *ListContextTrait) HandleRender() { self.list.ClampSelection() if self.renderOnlyVisibleLines { // Rendering only the visible area can save a lot of cell memory for // those views that support it. totalLength := self.list.Len() if self.getNonModelItems != nil { totalLength += len(self.getNonModelItems()) } self.GetViewTrait().SetContentLineCount(totalLength) startIdx, length := self.GetViewTrait().ViewPortYBounds() content := self.renderLines(startIdx, startIdx+length) self.GetViewTrait().SetViewPortContentAndClearEverythingElse(content) } else { content := self.renderLines(-1, -1) self.GetViewTrait().SetContent(content) } self.c.Render() self.setFooter() } func (self *ListContextTrait) OnSearchSelect(selectedLineIdx int) error { self.GetList().SetSelection(self.ViewIndexToModelIndex(selectedLineIdx)) self.HandleFocus(types.OnFocusOpts{}) return nil } func (self *ListContextTrait) IsItemVisible(item types.HasUrn) bool { startIdx, length := self.GetViewTrait().ViewPortYBounds() selectionStart := self.ViewIndexToModelIndex(startIdx) selectionEnd := self.ViewIndexToModelIndex(startIdx + length) for i := selectionStart; i < selectionEnd; i++ { iterItem := self.GetList().GetItem(i) if iterItem != nil && iterItem.URN() == item.URN() { return true } } return false } // By default, list contexts supports range select func (self *ListContextTrait) RangeSelectEnabled() bool { return true } func (self *ListContextTrait) RenderOnlyVisibleLines() bool { return self.renderOnlyVisibleLines } func (self *ListContextTrait) TotalContentHeight() int { result := self.list.Len() if self.getNonModelItems != nil { result += len(self.getNonModelItems()) } return result } lazygit-0.50.0+ds1/pkg/gui/context/list_renderer.go000066400000000000000000000077151500612110400221630ustar00rootroot00000000000000package context import ( "strings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "golang.org/x/exp/slices" ) type NonModelItem struct { // Where in the model this should be inserted Index int // Content to render Content string // The column from which to render the item Column int } type ListRenderer struct { list types.IList // Function to get the display strings for each model item in the given // range. startIdx and endIdx are model indices. For each model item, return // an array of strings, one for each column; the list renderer will take // care of aligning the columns appropriately. getDisplayStrings func(startIdx int, endIdx int) [][]string // Alignment for each column. If nil, the default is left alignment getColumnAlignments func() []utils.Alignment // Function to insert non-model items (e.g. section headers). If nil, no // such items are inserted getNonModelItems func() []*NonModelItem // The remaining fields are private and shouldn't be initialized by clients numNonModelItems int viewIndicesByModelIndex []int modelIndicesByViewIndex []int columnPositions []int } func (self *ListRenderer) GetList() types.IList { return self.list } func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int { modelIndex = lo.Clamp(modelIndex, 0, self.list.Len()) if self.viewIndicesByModelIndex != nil { return self.viewIndicesByModelIndex[modelIndex] } return modelIndex } func (self *ListRenderer) ViewIndexToModelIndex(viewIndex int) int { viewIndex = lo.Clamp(viewIndex, 0, self.list.Len()+self.numNonModelItems) if self.modelIndicesByViewIndex != nil { return self.modelIndicesByViewIndex[viewIndex] } return viewIndex } func (self *ListRenderer) ColumnPositions() []int { return self.columnPositions } // startIdx and endIdx are view indices, not model indices. If you want to // render the whole list, pass -1 for both. func (self *ListRenderer) renderLines(startIdx int, endIdx int) string { var columnAlignments []utils.Alignment if self.getColumnAlignments != nil { columnAlignments = self.getColumnAlignments() } nonModelItems := []*NonModelItem{} self.numNonModelItems = 0 if self.getNonModelItems != nil { nonModelItems = self.getNonModelItems() self.prepareConversionArrays(nonModelItems) } startModelIdx := 0 if startIdx == -1 { startIdx = 0 } else { startModelIdx = self.ViewIndexToModelIndex(startIdx) } endModelIdx := self.list.Len() if endIdx == -1 { endIdx = endModelIdx + len(nonModelItems) } else { endModelIdx = self.ViewIndexToModelIndex(endIdx) } lines, columnPositions := utils.RenderDisplayStrings( self.getDisplayStrings(startModelIdx, endModelIdx), columnAlignments) self.columnPositions = columnPositions lines = self.insertNonModelItems(nonModelItems, endIdx, startIdx, lines, columnPositions) return strings.Join(lines, "\n") } func (self *ListRenderer) prepareConversionArrays(nonModelItems []*NonModelItem) { self.numNonModelItems = len(nonModelItems) self.viewIndicesByModelIndex = lo.Range(self.list.Len() + 1) self.modelIndicesByViewIndex = lo.Range(self.list.Len() + 1) offset := 0 for _, item := range nonModelItems { for i := item.Index; i <= self.list.Len(); i++ { self.viewIndicesByModelIndex[i]++ } self.modelIndicesByViewIndex = slices.Insert( self.modelIndicesByViewIndex, item.Index+offset, self.modelIndicesByViewIndex[item.Index+offset]) offset++ } } func (self *ListRenderer) insertNonModelItems( nonModelItems []*NonModelItem, endIdx int, startIdx int, lines []string, columnPositions []int, ) []string { offset := 0 for _, item := range nonModelItems { if item.Index+offset >= endIdx { break } if item.Index+offset >= startIdx { padding := "" if columnPositions != nil { padding = strings.Repeat(" ", columnPositions[item.Column]) } lines = slices.Insert(lines, item.Index+offset-startIdx, padding+item.Content) } offset++ } return lines } lazygit-0.50.0+ds1/pkg/gui/context/list_renderer_test.go000066400000000000000000000153121500612110400232120ustar00rootroot00000000000000package context import ( "fmt" "strings" "testing" "github.com/samber/lo" "github.com/stretchr/testify/assert" ) // wrapping string in my own type to give it an ID method which is required for list items type mystring string func (self mystring) ID() string { return string(self) } func TestListRenderer_renderLines(t *testing.T) { scenarios := []struct { name string modelStrings []mystring nonModelIndices []int startIdx int endIdx int expectedOutput string }{ { name: "Render whole list", modelStrings: []mystring{"a", "b", "c"}, startIdx: 0, endIdx: 3, expectedOutput: ` a b c`, }, { name: "Partial list, beginning", modelStrings: []mystring{"a", "b", "c"}, startIdx: 0, endIdx: 2, expectedOutput: ` a b`, }, { name: "Partial list, end", modelStrings: []mystring{"a", "b", "c"}, startIdx: 1, endIdx: 3, expectedOutput: ` b c`, }, { name: "Pass an endIdx greater than the model length", modelStrings: []mystring{"a", "b", "c"}, startIdx: 2, endIdx: 5, expectedOutput: ` c`, }, { name: "Whole list with section headers", modelStrings: []mystring{"a", "b", "c"}, nonModelIndices: []int{1, 3}, startIdx: 0, endIdx: 5, expectedOutput: ` a --- 1 (0) --- b c --- 3 (1) ---`, }, { name: "Multiple consecutive headers", modelStrings: []mystring{"a", "b", "c"}, nonModelIndices: []int{0, 0, 2, 2, 2}, startIdx: 0, endIdx: 8, expectedOutput: ` --- 0 (0) --- --- 0 (1) --- a b --- 2 (2) --- --- 2 (3) --- --- 2 (4) --- c`, }, { name: "Partial list with headers, beginning", modelStrings: []mystring{"a", "b", "c"}, nonModelIndices: []int{1, 3}, startIdx: 0, endIdx: 3, expectedOutput: ` a --- 1 (0) --- b`, }, { name: "Partial list with headers, end (beyond end index)", modelStrings: []mystring{"a", "b", "c"}, nonModelIndices: []int{1, 3}, startIdx: 2, endIdx: 7, expectedOutput: ` b c --- 3 (1) ---`, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { viewModel := NewListViewModel(func() []mystring { return s.modelStrings }) var getNonModelItems func() []*NonModelItem if s.nonModelIndices != nil { getNonModelItems = func() []*NonModelItem { return lo.Map(s.nonModelIndices, func(modelIndex int, nonModelIndex int) *NonModelItem { return &NonModelItem{ Index: modelIndex, Content: fmt.Sprintf("--- %d (%d) ---", modelIndex, nonModelIndex), } }) } } self := &ListRenderer{ list: viewModel, getDisplayStrings: func(startIdx int, endIdx int) [][]string { return lo.Map(s.modelStrings[startIdx:endIdx], func(s mystring, _ int) []string { return []string{string(s)} }) }, getNonModelItems: getNonModelItems, } expectedOutput := strings.Join(lo.Map( strings.Split(strings.TrimPrefix(s.expectedOutput, "\n"), "\n"), func(line string, _ int) string { return strings.TrimSpace(line) }), "\n") assert.Equal(t, expectedOutput, self.renderLines(s.startIdx, s.endIdx)) }) } } type myint int func (self myint) ID() string { return fmt.Sprint(int(self)) } func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) { scenarios := []struct { name string numModelItems int nonModelIndices []int modelIndices []int expectedViewIndices []int viewIndices []int expectedModelIndices []int }{ { name: "no headers (no getNonModelItems provided)", numModelItems: 3, nonModelIndices: nil, // no get modelIndices: []int{-1, 0, 1, 2, 3, 4}, expectedViewIndices: []int{0, 0, 1, 2, 3, 3}, viewIndices: []int{-1, 0, 1, 2, 3, 4}, expectedModelIndices: []int{0, 0, 1, 2, 3, 3}, }, { name: "no headers (getNonModelItems returns zero items)", numModelItems: 3, nonModelIndices: []int{}, modelIndices: []int{-1, 0, 1, 2, 3, 4}, expectedViewIndices: []int{0, 0, 1, 2, 3, 3}, viewIndices: []int{-1, 0, 1, 2, 3, 4}, expectedModelIndices: []int{0, 0, 1, 2, 3, 3}, }, { name: "basic", numModelItems: 3, nonModelIndices: []int{1, 2}, /* 0: model 0 1: --- header 0 --- 2: model 1 3: --- header 1 --- 4: model 2 */ modelIndices: []int{-1, 0, 1, 2, 3, 4}, expectedViewIndices: []int{0, 0, 2, 4, 5, 5}, viewIndices: []int{-1, 0, 1, 2, 3, 4, 5, 6}, expectedModelIndices: []int{0, 0, 1, 1, 2, 2, 3, 3}, }, { name: "consecutive section headers", numModelItems: 3, nonModelIndices: []int{0, 0, 2, 2, 2, 3, 3}, /* 0: --- header 0 --- 1: --- header 1 --- 2: model 0 3: model 1 4: --- header 2 --- 5: --- header 3 --- 6: --- header 4 --- 7: model 2 8: --- header 5 --- 9: --- header 6 --- */ modelIndices: []int{-1, 0, 1, 2, 3, 4}, expectedViewIndices: []int{2, 2, 3, 7, 10, 10}, viewIndices: []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, expectedModelIndices: []int{0, 0, 0, 0, 1, 2, 2, 2, 2, 3, 3, 3, 3}, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { // Expect lists of equal length for each test: assert.Equal(t, len(s.modelIndices), len(s.expectedViewIndices)) assert.Equal(t, len(s.viewIndices), len(s.expectedModelIndices)) modelInts := lo.Map(lo.Range(s.numModelItems), func(i int, _ int) myint { return myint(i) }) viewModel := NewListViewModel(func() []myint { return modelInts }) var getNonModelItems func() []*NonModelItem if s.nonModelIndices != nil { getNonModelItems = func() []*NonModelItem { return lo.Map(s.nonModelIndices, func(modelIndex int, _ int) *NonModelItem { return &NonModelItem{Index: modelIndex, Content: ""} }) } } self := &ListRenderer{ list: viewModel, getDisplayStrings: func(startIdx int, endIdx int) [][]string { return lo.Map(modelInts[startIdx:endIdx], func(i myint, _ int) []string { return []string{fmt.Sprint(i)} }) }, getNonModelItems: getNonModelItems, } // Need to render first so that it knows the non-model items self.renderLines(-1, -1) for i := 0; i < len(s.modelIndices); i++ { assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i])) } for i := 0; i < len(s.viewIndices); i++ { assert.Equal(t, s.expectedModelIndices[i], self.ViewIndexToModelIndex(s.viewIndices[i])) } }) } } lazygit-0.50.0+ds1/pkg/gui/context/list_view_model.go000066400000000000000000000027441500612110400225040ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type HasID interface { ID() string } type ListViewModel[T HasID] struct { *traits.ListCursor getModel func() []T } func NewListViewModel[T HasID](getModel func() []T) *ListViewModel[T] { self := &ListViewModel[T]{ getModel: getModel, } self.ListCursor = traits.NewListCursor(func() int { return len(getModel()) }) return self } func (self *ListViewModel[T]) GetSelected() T { if self.Len() == 0 { return Zero[T]() } return self.getModel()[self.GetSelectedLineIdx()] } func (self *ListViewModel[T]) GetSelectedItemId() string { if self.Len() == 0 { return "" } return self.GetSelected().ID() } func (self *ListViewModel[T]) GetSelectedItems() ([]T, int, int) { if self.Len() == 0 { return nil, -1, -1 } startIdx, endIdx := self.GetSelectionRange() return self.getModel()[startIdx : endIdx+1], startIdx, endIdx } func (self *ListViewModel[T]) GetSelectedItemIds() ([]string, int, int) { selectedItems, startIdx, endIdx := self.GetSelectedItems() ids := lo.Map(selectedItems, func(item T, _ int) string { return item.ID() }) return ids, startIdx, endIdx } func (self *ListViewModel[T]) GetItems() []T { return self.getModel() } func Zero[T any]() T { return *new(T) } func (self *ListViewModel[T]) GetItem(index int) types.HasUrn { item := self.getModel()[index] return any(item).(types.HasUrn) } lazygit-0.50.0+ds1/pkg/gui/context/local_commits_context.go000066400000000000000000000210321500612110400236770ustar00rootroot00000000000000package context import ( "fmt" "log" "strings" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type LocalCommitsContext struct { *LocalCommitsViewModel *ListContextTrait *SearchTrait } var ( _ types.IListContext = (*LocalCommitsContext)(nil) _ types.DiffableContext = (*LocalCommitsContext)(nil) _ types.ISearchableContext = (*LocalCommitsContext)(nil) ) func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { viewModel := NewLocalCommitsViewModel( func() []*models.Commit { return c.Model().Commits }, c, ) getDisplayStrings := func(startIdx int, endIdx int) [][]string { var selectedCommitHashPtr *string if c.Context().Current().GetKey() == LOCAL_COMMITS_CONTEXT_KEY { selectedCommit := viewModel.GetSelected() if selectedCommit != nil { selectedCommitHashPtr = selectedCommit.HashPtr() } } hasRebaseUpdateRefsConfig := c.Git().Config.GetRebaseUpdateRefs() return presentation.GetCommitListDisplayStrings( c.Common, c.Model().Commits, c.Model().Branches, c.Model().CheckedOutBranch, hasRebaseUpdateRefsConfig, c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.Modes().CherryPicking.SelectedHashSet(), c.Modes().Diffing.Ref, c.Modes().MarkedBaseCommit.GetHash(), c.UserConfig().Gui.TimeFormat, c.UserConfig().Gui.ShortTimeFormat, time.Now(), c.UserConfig().Git.ParseEmoji, selectedCommitHashPtr, startIdx, endIdx, shouldShowGraph(c), c.Model().BisectInfo, ) } getNonModelItems := func() []*NonModelItem { result := []*NonModelItem{} if c.Model().WorkingTreeStateAtLastCommitRefresh.CanShowTodos() { if c.Model().WorkingTreeStateAtLastCommitRefresh.Rebasing { result = append(result, &NonModelItem{ Index: 0, Content: fmt.Sprintf("--- %s ---", c.Tr.PendingRebaseTodosSectionHeader), }) } if c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking || c.Model().WorkingTreeStateAtLastCommitRefresh.Reverting { _, firstCherryPickOrRevertTodo, found := lo.FindIndexOf( c.Model().Commits, func(c *models.Commit) bool { return c.Status == models.StatusCherryPickingOrReverting || c.Status == models.StatusConflicted }) if !found { firstCherryPickOrRevertTodo = 0 } label := lo.Ternary(c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking, c.Tr.PendingCherryPicksSectionHeader, c.Tr.PendingRevertsSectionHeader) result = append(result, &NonModelItem{ Index: firstCherryPickOrRevertTodo, Content: fmt.Sprintf("--- %s ---", label), }) } _, firstRealCommit, found := lo.FindIndexOf( c.Model().Commits, func(c *models.Commit) bool { return !c.IsTODO() }) if !found { firstRealCommit = 0 } result = append(result, &NonModelItem{ Index: firstRealCommit, Content: fmt.Sprintf("--- %s ---", c.Tr.CommitsSectionHeader), }) } return result } ctx := &LocalCommitsContext{ LocalCommitsViewModel: viewModel, SearchTrait: NewSearchTrait(c), ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Commits, WindowName: "commits", Key: LOCAL_COMMITS_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_SCREEN_MODE_CHANGES, NeedsRerenderOnHeightChange: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, getNonModelItems: getNonModelItems, }, c: c, refreshViewportOnChange: true, renderOnlyVisibleLines: true, }, } ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect)) return ctx } type LocalCommitsViewModel struct { *ListViewModel[*models.Commit] // If this is true we limit the amount of commits we load, for the sake of keeping things fast. // If the user attempts to scroll past the end of the list, we will load more commits. limitCommits bool // If this is true we'll use git log --all when fetching the commits. showWholeGitGraph bool } func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel { self := &LocalCommitsViewModel{ ListViewModel: NewListViewModel(getModel), limitCommits: true, showWholeGitGraph: c.UserConfig().Git.Log.ShowWholeGraph, } return self } func (self *LocalCommitsContext) CanRebase() bool { return true } func (self *LocalCommitsContext) GetSelectedRef() types.Ref { commit := self.GetSelected() if commit == nil { return nil } return commit } func (self *LocalCommitsContext) GetSelectedRefRangeForDiffFiles() *types.RefRange { commits, startIdx, endIdx := self.GetSelectedItems() if commits == nil || startIdx == endIdx { return nil } from := commits[len(commits)-1] to := commits[0] if from.IsTODO() || to.IsTODO() { return nil } return &types.RefRange{From: from, To: to} } // Returns the commit hash of the selected commit, or an empty string if no // commit is selected func (self *LocalCommitsContext) GetSelectedCommitHash() string { commit := self.GetSelected() if commit == nil { return "" } return commit.Hash() } func (self *LocalCommitsContext) SelectCommitByHash(hash string) bool { if hash == "" { return false } if _, idx, found := lo.FindIndexOf(self.GetItems(), func(c *models.Commit) bool { return c.Hash() == hash }); found { self.SetSelection(idx) return true } return false } func (self *LocalCommitsContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() return []string{itemId} } func (self *LocalCommitsContext) RefForAdjustingLineNumberInDiff() string { commits, _, _ := self.GetSelectedItems() if commits == nil { return "" } return commits[0].Hash() } func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), searchStr) } func (self *LocalCommitsViewModel) SetLimitCommits(value bool) { self.limitCommits = value } func (self *LocalCommitsViewModel) GetLimitCommits() bool { return self.limitCommits } func (self *LocalCommitsViewModel) SetShowWholeGitGraph(value bool) { self.showWholeGitGraph = value } func (self *LocalCommitsViewModel) GetShowWholeGitGraph() bool { return self.showWholeGitGraph } func (self *LocalCommitsViewModel) GetCommits() []*models.Commit { return self.getModel() } func shouldShowGraph(c *ContextCommon) bool { if c.Modes().Filtering.Active() { return false } value := c.GetAppState().GitLogShowGraph switch value { case "always": return true case "never": return false case "when-maximised": return c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL } log.Fatalf("Unknown value for git.log.showGraph: %s. Expected one of: 'always', 'never', 'when-maximised'", value) return false } func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPositions []int, searchStr string) []gocui.SearchPosition { if columnPositions == nil { // This should never happen. We are being called at a time where our // entire view content is scrolled out of view, so that we didn't draw // anything the last time we rendered. If we run into a scenario where // this happens, we should fix it, but until we found them all, at least // make sure we don't crash. return []gocui.SearchPosition{} } normalize := lo.Ternary(caseSensitive, func(s string) string { return s }, strings.ToLower) return lo.FilterMap(commits, func(commit *models.Commit, idx int) (gocui.SearchPosition, bool) { // The XStart and XEnd values are only used if the search string can't // be found in the view. This can really only happen if the user is // searching for a commit hash that is longer than the truncated hash // that we render. So we just set the XStart and XEnd values to the // start and end of the commit hash column, which is the second one. result := gocui.SearchPosition{XStart: columnPositions[1], XEnd: columnPositions[2] - 1, Y: idx} return result, strings.Contains(normalize(commit.Hash()), searchStr) || strings.Contains(normalize(commit.Name), searchStr) || strings.Contains(normalize(commit.ExtraInfo), searchStr) // allow searching for tags }) } lazygit-0.50.0+ds1/pkg/gui/context/main_context.go000066400000000000000000000016171500612110400220050ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type MainContext struct { *SimpleContext *SearchTrait } var _ types.ISearchableContext = (*MainContext)(nil) func NewMainContext( view *gocui.View, windowName string, key types.ContextKey, c *ContextCommon, ) *MainContext { ctx := &MainContext{ SimpleContext: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.MAIN_CONTEXT, View: view, WindowName: windowName, Key: key, Focusable: true, HighlightOnFocus: false, })), SearchTrait: NewSearchTrait(c), } ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(int) error { return nil })) return ctx } func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { return nil } lazygit-0.50.0+ds1/pkg/gui/context/menu_context.go000066400000000000000000000133551500612110400220270ustar00rootroot00000000000000package context import ( "errors" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type MenuContext struct { c *ContextCommon *MenuViewModel *ListContextTrait } var _ types.IListContext = (*MenuContext)(nil) func NewMenuContext( c *ContextCommon, ) *MenuContext { viewModel := NewMenuViewModel(c) return &MenuContext{ c: c, MenuViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Menu, WindowName: "menu", Key: "menu", Kind: types.TEMPORARY_POPUP, Focusable: true, HasUncontrolledBounds: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: viewModel.GetDisplayStrings, getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment }, getNonModelItems: viewModel.GetNonModelItems, }, c: c, }, } } type MenuViewModel struct { c *ContextCommon menuItems []*types.MenuItem prompt string promptLines []string columnAlignment []utils.Alignment *FilteredListViewModel[*types.MenuItem] } func NewMenuViewModel(c *ContextCommon) *MenuViewModel { self := &MenuViewModel{ menuItems: nil, c: c, } self.FilteredListViewModel = NewFilteredListViewModel( func() []*types.MenuItem { return self.menuItems }, func(item *types.MenuItem) []string { return item.LabelColumns }, ) return self } func (self *MenuViewModel) SetMenuItems(items []*types.MenuItem, columnAlignment []utils.Alignment) { self.menuItems = items self.columnAlignment = columnAlignment } func (self *MenuViewModel) GetPrompt() string { return self.prompt } func (self *MenuViewModel) SetPrompt(prompt string) { self.prompt = prompt self.promptLines = nil } func (self *MenuViewModel) GetPromptLines() []string { return self.promptLines } func (self *MenuViewModel) SetPromptLines(promptLines []string) { self.promptLines = promptLines } // TODO: move into presentation package func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { menuItems := self.FilteredListViewModel.GetItems() return lo.Map(menuItems, func(item *types.MenuItem, _ int) []string { displayStrings := item.LabelColumns if item.DisabledReason != nil { displayStrings[0] = style.FgDefault.SetStrikethrough().Sprint(displayStrings[0]) } keyLabel := "" if item.Key != nil { keyLabel = style.FgCyan.Sprint(keybindings.LabelFromKey(item.Key)) } checkMark := "" switch item.Widget { case types.MenuWidgetNone: // do nothing case types.MenuWidgetRadioButtonSelected: checkMark = "(•)" case types.MenuWidgetRadioButtonUnselected: checkMark = "( )" case types.MenuWidgetCheckboxSelected: checkMark = "[✓]" case types.MenuWidgetCheckboxUnselected: checkMark = "[ ]" } displayStrings = utils.Prepend(displayStrings, keyLabel, checkMark) return displayStrings }) } func (self *MenuViewModel) GetNonModelItems() []*NonModelItem { result := []*NonModelItem{} result = append(result, lo.Map(self.promptLines, func(line string, _ int) *NonModelItem { return &NonModelItem{ Index: 0, Column: 0, Content: line, } })...) // Don't display section headers when we are filtering, and the filter mode // is fuzzy. The reason is that filtering changes the order of the items // (they are sorted by best match), so all the sections would be messed up. if self.FilteredListViewModel.IsFiltering() && self.c.UserConfig().Gui.UseFuzzySearch() { return result } menuItems := self.FilteredListViewModel.GetItems() var prevSection *types.MenuSection = nil for i, menuItem := range menuItems { if menuItem.Section != nil && menuItem.Section != prevSection { if prevSection != nil { result = append(result, &NonModelItem{ Index: i, Column: 1, Content: "", }) } result = append(result, &NonModelItem{ Index: i, Column: 1, Content: style.FgGreen.SetBold().Sprintf("--- %s ---", menuItem.Section.Title), }) prevSection = menuItem.Section } } return result } func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { basicBindings := self.ListContextTrait.GetKeybindings(opts) menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool { return item.Key != nil }) menuItemBindings := lo.Map(menuItemsWithKeys, func(item *types.MenuItem, _ int) *types.Binding { return &types.Binding{ Key: item.Key, Handler: func() error { return self.OnMenuPress(item) }, } }) // appending because that means the menu item bindings have lower precedence. // So if a basic binding is to escape from the menu, we want that to still be // what happens when you press escape. This matters when we're showing the menu // for all keybindings of say the files context. return append(basicBindings, menuItemBindings...) } func (self *MenuContext) OnMenuPress(selectedItem *types.MenuItem) error { if selectedItem != nil && selectedItem.DisabledReason != nil { if selectedItem.DisabledReason.ShowErrorInPanel { return errors.New(selectedItem.DisabledReason.Text) } self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + selectedItem.DisabledReason.Text) return nil } self.c.Context().Pop() if selectedItem == nil { return nil } if err := selectedItem.OnPress(); err != nil { return err } return nil } // There is currently no need to use range-select in a menu so we're disabling it. func (self *MenuContext) RangeSelectEnabled() bool { return false } lazygit-0.50.0+ds1/pkg/gui/context/merge_conflicts_context.go000066400000000000000000000055121500612110400242220ustar00rootroot00000000000000package context import ( "math" "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/sasha-s/go-deadlock" ) type MergeConflictsContext struct { types.Context viewModel *ConflictsViewModel c *ContextCommon mutex *deadlock.Mutex } type ConflictsViewModel struct { state *mergeconflicts.State // userVerticalScrolling tells us if the user has started scrolling through the file themselves // in which case we won't auto-scroll to a conflict. userVerticalScrolling bool } func NewMergeConflictsContext( c *ContextCommon, ) *MergeConflictsContext { viewModel := &ConflictsViewModel{ state: mergeconflicts.NewState(), userVerticalScrolling: false, } return &MergeConflictsContext{ viewModel: viewModel, mutex: &deadlock.Mutex{}, Context: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.MAIN_CONTEXT, View: c.Views().MergeConflicts, WindowName: "main", Key: MERGE_CONFLICTS_CONTEXT_KEY, Focusable: true, HighlightOnFocus: true, }), ), c: c, } } func (self *MergeConflictsContext) GetState() *mergeconflicts.State { return self.viewModel.state } func (self *MergeConflictsContext) SetState(state *mergeconflicts.State) { self.viewModel.state = state } func (self *MergeConflictsContext) GetMutex() *deadlock.Mutex { return self.mutex } func (self *MergeConflictsContext) SetUserScrolling(isScrolling bool) { self.viewModel.userVerticalScrolling = isScrolling } func (self *MergeConflictsContext) IsUserScrolling() bool { return self.viewModel.userVerticalScrolling } func (self *MergeConflictsContext) RenderAndFocus() { self.setContent() self.FocusSelection() self.c.Render() } func (self *MergeConflictsContext) Render() error { self.setContent() self.c.Render() return nil } func (self *MergeConflictsContext) GetContentToRender() string { if self.GetState() == nil { return "" } return mergeconflicts.ColoredConflictFile(self.GetState()) } func (self *MergeConflictsContext) setContent() { self.GetView().SetContent(self.GetContentToRender()) } func (self *MergeConflictsContext) FocusSelection() { if !self.IsUserScrolling() { self.GetView().SetOriginY(self.GetOriginY()) } self.SetSelectedLineRange() } func (self *MergeConflictsContext) SetSelectedLineRange() { startIdx, endIdx := self.GetState().GetSelectedRange() view := self.GetView() originY := view.OriginY() // As far as the view is concerned, we are always selecting a range view.SetRangeSelectStart(startIdx) view.SetCursorY(endIdx - originY) } func (self *MergeConflictsContext) GetOriginY() int { view := self.GetView() conflictMiddle := self.GetState().GetConflictMiddle() return int(math.Max(0, float64(conflictMiddle-(view.InnerHeight()/2)))) } lazygit-0.50.0+ds1/pkg/gui/context/parent_context_mgr.go000066400000000000000000000006101500612110400232070ustar00rootroot00000000000000package context import "github.com/jesseduffield/lazygit/pkg/gui/types" type ParentContextMgr struct { ParentContext types.Context } var _ types.ParentContexter = (*ParentContextMgr)(nil) func (self *ParentContextMgr) SetParentContext(context types.Context) { self.ParentContext = context } func (self *ParentContextMgr) GetParentContext() types.Context { return self.ParentContext } lazygit-0.50.0+ds1/pkg/gui/context/patch_explorer_context.go000066400000000000000000000074431500612110400241030ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" "github.com/jesseduffield/lazygit/pkg/gui/types" deadlock "github.com/sasha-s/go-deadlock" ) type PatchExplorerContext struct { *SimpleContext *SearchTrait state *patch_exploring.State viewTrait *ViewTrait getIncludedLineIndices func() []int c *ContextCommon mutex *deadlock.Mutex } var ( _ types.IPatchExplorerContext = (*PatchExplorerContext)(nil) _ types.ISearchableContext = (*PatchExplorerContext)(nil) ) func NewPatchExplorerContext( view *gocui.View, windowName string, key types.ContextKey, getIncludedLineIndices func() []int, c *ContextCommon, ) *PatchExplorerContext { ctx := &PatchExplorerContext{ state: nil, viewTrait: NewViewTrait(view), c: c, mutex: &deadlock.Mutex{}, getIncludedLineIndices: getIncludedLineIndices, SimpleContext: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: view, WindowName: windowName, Key: key, Kind: types.MAIN_CONTEXT, Focusable: true, HighlightOnFocus: true, NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES, })), SearchTrait: NewSearchTrait(c), } ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper( func(selectedLineIdx int) error { ctx.GetMutex().Lock() defer ctx.GetMutex().Unlock() ctx.NavigateTo(selectedLineIdx) return nil }), ) ctx.SetHandleRenderFunc(ctx.OnViewWidthChanged) return ctx } func (self *PatchExplorerContext) IsPatchExplorerContext() {} func (self *PatchExplorerContext) GetState() *patch_exploring.State { return self.state } func (self *PatchExplorerContext) SetState(state *patch_exploring.State) { self.state = state } func (self *PatchExplorerContext) GetViewTrait() types.IViewTrait { return self.viewTrait } func (self *PatchExplorerContext) GetIncludedLineIndices() []int { return self.getIncludedLineIndices() } func (self *PatchExplorerContext) RenderAndFocus() { self.setContent() self.FocusSelection() self.c.Render() } func (self *PatchExplorerContext) Render() { self.setContent() self.c.Render() } func (self *PatchExplorerContext) Focus() { self.FocusSelection() self.c.Render() } func (self *PatchExplorerContext) setContent() { self.GetView().SetContent(self.GetContentToRender()) } func (self *PatchExplorerContext) FocusSelection() { view := self.GetView() state := self.GetState() bufferHeight := view.InnerHeight() _, origin := view.Origin() numLines := view.ViewLinesHeight() newOriginY := state.CalculateOrigin(origin, bufferHeight, numLines) view.SetOriginY(newOriginY) startIdx, endIdx := state.SelectedViewRange() // As far as the view is concerned, we are always selecting a range view.SetRangeSelectStart(startIdx) view.SetCursorY(endIdx - newOriginY) } func (self *PatchExplorerContext) GetContentToRender() string { if self.GetState() == nil { return "" } return self.GetState().RenderForLineIndices(self.GetIncludedLineIndices()) } func (self *PatchExplorerContext) NavigateTo(selectedLineIdx int) { self.GetState().SetLineSelectMode() self.GetState().SelectLine(selectedLineIdx) self.RenderAndFocus() } func (self *PatchExplorerContext) GetMutex() *deadlock.Mutex { return self.mutex } func (self *PatchExplorerContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { return nil } func (self *PatchExplorerContext) OnViewWidthChanged() { if state := self.GetState(); state != nil { state.OnViewWidthChanged(self.GetView()) self.setContent() self.RenderAndFocus() } } lazygit-0.50.0+ds1/pkg/gui/context/reflog_commits_context.go000066400000000000000000000050301500612110400240630ustar00rootroot00000000000000package context import ( "time" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ReflogCommitsContext struct { *FilteredListViewModel[*models.Commit] *ListContextTrait } var ( _ types.IListContext = (*ReflogCommitsContext)(nil) _ types.DiffableContext = (*ReflogCommitsContext)(nil) ) func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext { viewModel := NewFilteredListViewModel( func() []*models.Commit { return c.Model().FilteredReflogCommits }, func(commit *models.Commit) []string { return []string{commit.ShortHash(), commit.Name} }, ) getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetReflogCommitListDisplayStrings( viewModel.GetItems(), c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.Modes().CherryPicking.SelectedHashSet(), c.Modes().Diffing.Ref, time.Now(), c.UserConfig().Gui.TimeFormat, c.UserConfig().Gui.ShortTimeFormat, c.UserConfig().Git.ParseEmoji, ) } return &ReflogCommitsContext{ FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().ReflogCommits, WindowName: "commits", Key: REFLOG_COMMITS_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_SCREEN_MODE_CHANGES, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } } func (self *ReflogCommitsContext) CanRebase() bool { return false } func (self *ReflogCommitsContext) GetSelectedRef() types.Ref { commit := self.GetSelected() if commit == nil { return nil } return commit } func (self *ReflogCommitsContext) GetSelectedRefRangeForDiffFiles() *types.RefRange { // It doesn't make much sense to show a range diff between two reflog entries. return nil } func (self *ReflogCommitsContext) GetCommits() []*models.Commit { return self.getModel() } func (self *ReflogCommitsContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() return []string{itemId} } func (self *ReflogCommitsContext) RefForAdjustingLineNumberInDiff() string { return self.GetSelectedItemId() } func (self *ReflogCommitsContext) ShowBranchHeadsInSubCommits() bool { return false } lazygit-0.50.0+ds1/pkg/gui/context/remote_branches_context.go000066400000000000000000000045341500612110400242220ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type RemoteBranchesContext struct { *FilteredListViewModel[*models.RemoteBranch] *ListContextTrait *DynamicTitleBuilder } var ( _ types.IListContext = (*RemoteBranchesContext)(nil) _ types.DiffableContext = (*RemoteBranchesContext)(nil) ) func NewRemoteBranchesContext( c *ContextCommon, ) *RemoteBranchesContext { viewModel := NewFilteredListViewModel( func() []*models.RemoteBranch { return c.Model().RemoteBranches }, func(remoteBranch *models.RemoteBranch) []string { return []string{remoteBranch.Name} }, ) getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetRemoteBranchListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) } return &RemoteBranchesContext{ FilteredListViewModel: viewModel, DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.RemoteBranchesDynamicTitle), ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().RemoteBranches, WindowName: "branches", Key: REMOTE_BRANCHES_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, Transient: true, NeedsRerenderOnHeightChange: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } } func (self *RemoteBranchesContext) GetSelectedRef() types.Ref { remoteBranch := self.GetSelected() if remoteBranch == nil { return nil } return remoteBranch } func (self *RemoteBranchesContext) GetSelectedRefs() ([]types.Ref, int, int) { items, startIdx, endIdx := self.GetSelectedItems() refs := lo.Map(items, func(item *models.RemoteBranch, _ int) types.Ref { return item }) return refs, startIdx, endIdx } func (self *RemoteBranchesContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() return []string{itemId} } func (self *RemoteBranchesContext) RefForAdjustingLineNumberInDiff() string { return self.GetSelectedItemId() } func (self *RemoteBranchesContext) ShowBranchHeadsInSubCommits() bool { return true } lazygit-0.50.0+ds1/pkg/gui/context/remotes_context.go000066400000000000000000000027421500612110400225370ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type RemotesContext struct { *FilteredListViewModel[*models.Remote] *ListContextTrait } var ( _ types.IListContext = (*RemotesContext)(nil) _ types.DiffableContext = (*RemotesContext)(nil) ) func NewRemotesContext(c *ContextCommon) *RemotesContext { viewModel := NewFilteredListViewModel( func() []*models.Remote { return c.Model().Remotes }, func(remote *models.Remote) []string { return []string{remote.Name} }, ) getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetRemoteListDisplayStrings( viewModel.GetItems(), c.Modes().Diffing.Ref, c.State().GetItemOperation, c.Tr, c.UserConfig()) } return &RemotesContext{ FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Remotes, WindowName: "branches", Key: REMOTES_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } } func (self *RemotesContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() return []string{itemId} } func (self *RemotesContext) RefForAdjustingLineNumberInDiff() string { return "" } lazygit-0.50.0+ds1/pkg/gui/context/search_trait.go000066400000000000000000000035541500612110400217670ustar00rootroot00000000000000package context import ( "fmt" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/theme" ) type SearchTrait struct { c *ContextCommon *SearchHistory searchString string } func NewSearchTrait(c *ContextCommon) *SearchTrait { return &SearchTrait{ c: c, SearchHistory: NewSearchHistory(), } } func (self *SearchTrait) GetSearchString() string { return self.searchString } func (self *SearchTrait) SetSearchString(searchString string) { self.searchString = searchString } func (self *SearchTrait) ClearSearchString() { self.SetSearchString("") } // used for type switch func (self *SearchTrait) IsSearchableContext() {} func (self *SearchTrait) onSelectItemWrapper(innerFunc func(int) error) func(int, int, int) error { return func(selectedLineIdx int, index int, total int) error { self.RenderSearchStatus(index, total) if total != 0 { if err := innerFunc(selectedLineIdx); err != nil { return err } } return nil } } func (self *SearchTrait) RenderSearchStatus(index int, total int) { keybindingConfig := self.c.UserConfig().Keybinding if total == 0 { self.c.SetViewContent( self.c.Views().Search, fmt.Sprintf( self.c.Tr.NoMatchesFor, self.searchString, theme.OptionsFgColor.Sprintf(self.c.Tr.ExitSearchMode, keybindings.Label(keybindingConfig.Universal.Return)), ), ) } else { self.c.SetViewContent( self.c.Views().Search, fmt.Sprintf( self.c.Tr.MatchesFor, self.searchString, index+1, total, theme.OptionsFgColor.Sprintf( self.c.Tr.SearchKeybindings, keybindings.Label(keybindingConfig.Universal.NextMatch), keybindings.Label(keybindingConfig.Universal.PrevMatch), keybindings.Label(keybindingConfig.Universal.Return), ), ), ) } } func (self *SearchTrait) IsSearching() bool { return self.searchString != "" } lazygit-0.50.0+ds1/pkg/gui/context/setup.go000066400000000000000000000106611500612110400204540ustar00rootroot00000000000000package context import "github.com/jesseduffield/lazygit/pkg/gui/types" func NewContextTree(c *ContextCommon) *ContextTree { commitFilesContext := NewCommitFilesContext(c) return &ContextTree{ Global: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.GLOBAL_CONTEXT, View: nil, // TODO: see if this breaks anything WindowName: "", Key: GLOBAL_CONTEXT_KEY, Focusable: false, HasUncontrolledBounds: true, // setting to true because the global context doesn't even have a view }), ), Status: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.SIDE_CONTEXT, View: c.Views().Status, WindowName: "status", Key: STATUS_CONTEXT_KEY, Focusable: true, }), ), Files: NewWorkingTreeContext(c), Submodules: NewSubmodulesContext(c), Menu: NewMenuContext(c), Remotes: NewRemotesContext(c), Worktrees: NewWorktreesContext(c), RemoteBranches: NewRemoteBranchesContext(c), LocalCommits: NewLocalCommitsContext(c), CommitFiles: commitFilesContext, ReflogCommits: NewReflogCommitsContext(c), SubCommits: NewSubCommitsContext(c), Branches: NewBranchesContext(c), Tags: NewTagsContext(c), Stash: NewStashContext(c), Suggestions: NewSuggestionsContext(c), Normal: NewMainContext(c.Views().Main, "main", NORMAL_MAIN_CONTEXT_KEY, c), NormalSecondary: NewMainContext(c.Views().Secondary, "secondary", NORMAL_SECONDARY_CONTEXT_KEY, c), Staging: NewPatchExplorerContext( c.Views().Staging, "main", STAGING_MAIN_CONTEXT_KEY, func() []int { return nil }, c, ), StagingSecondary: NewPatchExplorerContext( c.Views().StagingSecondary, "secondary", STAGING_SECONDARY_CONTEXT_KEY, func() []int { return nil }, c, ), CustomPatchBuilder: NewPatchExplorerContext( c.Views().PatchBuilding, "main", PATCH_BUILDING_MAIN_CONTEXT_KEY, func() []int { filename := commitFilesContext.GetSelectedPath() includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(filename) if err != nil { c.Log.Error(err) return nil } return includedLineIndices }, c, ), CustomPatchBuilderSecondary: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.MAIN_CONTEXT, View: c.Views().PatchBuildingSecondary, WindowName: "secondary", Key: PATCH_BUILDING_SECONDARY_CONTEXT_KEY, Focusable: false, }), ), MergeConflicts: NewMergeConflictsContext( c, ), Confirmation: NewConfirmationContext(c), CommitMessage: NewCommitMessageContext(c), CommitDescription: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.PERSISTENT_POPUP, View: c.Views().CommitDescription, WindowName: "commitDescription", Key: COMMIT_DESCRIPTION_CONTEXT_KEY, Focusable: true, HasUncontrolledBounds: true, }), ), Search: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.PERSISTENT_POPUP, View: c.Views().Search, WindowName: "search", Key: SEARCH_CONTEXT_KEY, Focusable: true, }), ), CommandLog: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.EXTRAS_CONTEXT, View: c.Views().Extras, WindowName: "extras", Key: COMMAND_LOG_CONTEXT_KEY, Focusable: true, }), ), Snake: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.SIDE_CONTEXT, View: c.Views().Snake, WindowName: "files", Key: SNAKE_CONTEXT_KEY, Focusable: true, }), ), Options: NewDisplayContext(OPTIONS_CONTEXT_KEY, c.Views().Options, "options"), AppStatus: NewDisplayContext(APP_STATUS_CONTEXT_KEY, c.Views().AppStatus, "appStatus"), SearchPrefix: NewDisplayContext(SEARCH_PREFIX_CONTEXT_KEY, c.Views().SearchPrefix, "searchPrefix"), Information: NewDisplayContext(INFORMATION_CONTEXT_KEY, c.Views().Information, "information"), Limit: NewDisplayContext(LIMIT_CONTEXT_KEY, c.Views().Limit, "limit"), StatusSpacer1: NewDisplayContext(STATUS_SPACER1_CONTEXT_KEY, c.Views().StatusSpacer1, "statusSpacer1"), StatusSpacer2: NewDisplayContext(STATUS_SPACER2_CONTEXT_KEY, c.Views().StatusSpacer2, "statusSpacer2"), } } lazygit-0.50.0+ds1/pkg/gui/context/simple_context.go000066400000000000000000000030531500612110400223460ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type SimpleContext struct { *BaseContext handleRenderFunc func() } func NewSimpleContext(baseContext *BaseContext) *SimpleContext { return &SimpleContext{ BaseContext: baseContext, } } var _ types.Context = &SimpleContext{} // A Display context only renders a view. It has no keybindings and is not focusable. func NewDisplayContext(key types.ContextKey, view *gocui.View, windowName string) types.Context { return NewSimpleContext( NewBaseContext(NewBaseContextOpts{ Kind: types.DISPLAY_CONTEXT, Key: key, View: view, WindowName: windowName, Focusable: false, Transient: false, }), ) } func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) { if self.highlightOnFocus { self.GetViewTrait().SetHighlight(true) } if self.onFocusFn != nil { self.onFocusFn(opts) } if self.onRenderToMainFn != nil { self.onRenderToMainFn() } } func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) { self.GetViewTrait().SetHighlight(false) self.view.SetOriginX(0) if self.onFocusLostFn != nil { self.onFocusLostFn(opts) } } func (self *SimpleContext) FocusLine() { } func (self *SimpleContext) HandleRender() { if self.handleRenderFunc != nil { self.handleRenderFunc() } } func (self *SimpleContext) SetHandleRenderFunc(f func()) { self.handleRenderFunc = f } func (self *SimpleContext) HandleRenderToMain() { if self.onRenderToMainFn != nil { self.onRenderToMainFn() } } lazygit-0.50.0+ds1/pkg/gui/context/stash_context.go000066400000000000000000000034761500612110400222100ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type StashContext struct { *FilteredListViewModel[*models.StashEntry] *ListContextTrait } var ( _ types.IListContext = (*StashContext)(nil) _ types.DiffableContext = (*StashContext)(nil) ) func NewStashContext( c *ContextCommon, ) *StashContext { viewModel := NewFilteredListViewModel( func() []*models.StashEntry { return c.Model().StashEntries }, func(stashEntry *models.StashEntry) []string { return []string{stashEntry.Name} }, ) getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetStashEntryListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) } return &StashContext{ FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Stash, WindowName: "stash", Key: STASH_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } } func (self *StashContext) CanRebase() bool { return false } func (self *StashContext) GetSelectedRef() types.Ref { stash := self.GetSelected() if stash == nil { return nil } return stash } func (self *StashContext) GetSelectedRefRangeForDiffFiles() *types.RefRange { // It doesn't make much sense to show a range diff between two stash entries. return nil } func (self *StashContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() return []string{itemId} } func (self *StashContext) RefForAdjustingLineNumberInDiff() string { return self.GetSelectedItemId() } lazygit-0.50.0+ds1/pkg/gui/context/sub_commits_context.go000066400000000000000000000141511500612110400234020ustar00rootroot00000000000000package context import ( "fmt" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type SubCommitsContext struct { c *ContextCommon *SubCommitsViewModel *ListContextTrait *DynamicTitleBuilder *SearchTrait } var ( _ types.IListContext = (*SubCommitsContext)(nil) _ types.DiffableContext = (*SubCommitsContext)(nil) _ types.ISearchableContext = (*SubCommitsContext)(nil) ) func NewSubCommitsContext( c *ContextCommon, ) *SubCommitsContext { viewModel := &SubCommitsViewModel{ ListViewModel: NewListViewModel( func() []*models.Commit { return c.Model().SubCommits }, ), ref: nil, limitCommits: true, } getDisplayStrings := func(startIdx int, endIdx int) [][]string { // This can happen if a sub-commits view is asked to be rerendered while // it is invisible; for example when switching screen modes, which // rerenders all views. if viewModel.GetRef() == nil { return [][]string{} } var selectedCommitHashPtr *string if c.Context().Current().GetKey() == SUB_COMMITS_CONTEXT_KEY { selectedCommit := viewModel.GetSelected() if selectedCommit != nil { selectedCommitHashPtr = selectedCommit.HashPtr() } } branches := []*models.Branch{} if viewModel.GetShowBranchHeads() { branches = c.Model().Branches } hasRebaseUpdateRefsConfig := c.Git().Config.GetRebaseUpdateRefs() return presentation.GetCommitListDisplayStrings( c.Common, c.Model().SubCommits, branches, viewModel.GetRef().RefName(), hasRebaseUpdateRefsConfig, c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.Modes().CherryPicking.SelectedHashSet(), c.Modes().Diffing.Ref, "", c.UserConfig().Gui.TimeFormat, c.UserConfig().Gui.ShortTimeFormat, time.Now(), c.UserConfig().Git.ParseEmoji, selectedCommitHashPtr, startIdx, endIdx, shouldShowGraph(c), git_commands.NewNullBisectInfo(), ) } getNonModelItems := func() []*NonModelItem { result := []*NonModelItem{} if viewModel.GetRefToShowDivergenceFrom() != "" { _, upstreamIdx, found := lo.FindIndexOf( c.Model().SubCommits, func(c *models.Commit) bool { return c.Divergence == models.DivergenceRight }) if !found { upstreamIdx = 0 } result = append(result, &NonModelItem{ Index: upstreamIdx, Content: fmt.Sprintf("--- %s ---", c.Tr.DivergenceSectionHeaderRemote), }) _, localIdx, found := lo.FindIndexOf( c.Model().SubCommits, func(c *models.Commit) bool { return c.Divergence == models.DivergenceLeft }) if !found { localIdx = len(c.Model().SubCommits) } result = append(result, &NonModelItem{ Index: localIdx, Content: fmt.Sprintf("--- %s ---", c.Tr.DivergenceSectionHeaderLocal), }) } return result } ctx := &SubCommitsContext{ c: c, SubCommitsViewModel: viewModel, SearchTrait: NewSearchTrait(c), DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.SubCommitsDynamicTitle), ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().SubCommits, WindowName: "branches", Key: SUB_COMMITS_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, Transient: true, NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_SCREEN_MODE_CHANGES, NeedsRerenderOnHeightChange: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, getNonModelItems: getNonModelItems, }, c: c, refreshViewportOnChange: true, renderOnlyVisibleLines: true, }, } ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect)) return ctx } type SubCommitsViewModel struct { // name of the ref that the sub-commits are shown for ref types.Ref refToShowDivergenceFrom string *ListViewModel[*models.Commit] limitCommits bool showBranchHeads bool } func (self *SubCommitsViewModel) SetRef(ref types.Ref) { self.ref = ref } func (self *SubCommitsViewModel) GetRef() types.Ref { return self.ref } func (self *SubCommitsViewModel) SetRefToShowDivergenceFrom(ref string) { self.refToShowDivergenceFrom = ref } func (self *SubCommitsViewModel) GetRefToShowDivergenceFrom() string { return self.refToShowDivergenceFrom } func (self *SubCommitsViewModel) SetShowBranchHeads(value bool) { self.showBranchHeads = value } func (self *SubCommitsViewModel) GetShowBranchHeads() bool { return self.showBranchHeads } func (self *SubCommitsContext) CanRebase() bool { return false } func (self *SubCommitsContext) GetSelectedRef() types.Ref { commit := self.GetSelected() if commit == nil { return nil } return commit } func (self *SubCommitsContext) GetSelectedRefRangeForDiffFiles() *types.RefRange { commits, startIdx, endIdx := self.GetSelectedItems() if commits == nil || startIdx == endIdx { return nil } from := commits[len(commits)-1] to := commits[0] if from.Divergence != to.Divergence { return nil } return &types.RefRange{From: from, To: to} } func (self *SubCommitsContext) GetCommits() []*models.Commit { return self.getModel() } func (self *SubCommitsContext) SetLimitCommits(value bool) { self.limitCommits = value } func (self *SubCommitsContext) GetLimitCommits() bool { return self.limitCommits } func (self *SubCommitsContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() return []string{itemId} } func (self *SubCommitsContext) RefForAdjustingLineNumberInDiff() string { commits, _, _ := self.GetSelectedItems() if commits == nil { return "" } return commits[0].Hash() } func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), searchStr) } lazygit-0.50.0+ds1/pkg/gui/context/submodules_context.go000066400000000000000000000023121500612110400232340ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type SubmodulesContext struct { *FilteredListViewModel[*models.SubmoduleConfig] *ListContextTrait } var _ types.IListContext = (*SubmodulesContext)(nil) func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext { viewModel := NewFilteredListViewModel( func() []*models.SubmoduleConfig { return c.Model().Submodules }, func(submodule *models.SubmoduleConfig) []string { return []string{submodule.FullName()} }, ) getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetSubmoduleListDisplayStrings(viewModel.GetItems()) } return &SubmodulesContext{ FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Submodules, WindowName: "files", Key: SUBMODULES_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } } lazygit-0.50.0+ds1/pkg/gui/context/suggestions_context.go000066400000000000000000000047141500612110400234340ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/tasks" ) type SuggestionsContext struct { *ListViewModel[*types.Suggestion] *ListContextTrait State *SuggestionsContextState } type SuggestionsContextState struct { Suggestions []*types.Suggestion OnConfirm func() error OnClose func() error OnDeleteSuggestion func() error AsyncHandler *tasks.AsyncHandler AllowEditSuggestion bool // FindSuggestions will take a string that the user has typed into a prompt // and return a slice of suggestions which match that string. FindSuggestions func(string) []*types.Suggestion } var _ types.IListContext = (*SuggestionsContext)(nil) func NewSuggestionsContext( c *ContextCommon, ) *SuggestionsContext { state := &SuggestionsContextState{ AsyncHandler: tasks.NewAsyncHandler(c.OnWorker), } getModel := func() []*types.Suggestion { return state.Suggestions } getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetSuggestionListDisplayStrings(state.Suggestions) } viewModel := NewListViewModel(getModel) return &SuggestionsContext{ State: state, ListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Suggestions, WindowName: "suggestions", Key: SUGGESTIONS_CONTEXT_KEY, Kind: types.PERSISTENT_POPUP, Focusable: true, HasUncontrolledBounds: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } } func (self *SuggestionsContext) SetSuggestions(suggestions []*types.Suggestion) { self.State.Suggestions = suggestions self.SetSelection(0) self.c.ResetViewOrigin(self.GetView()) self.HandleRender() } func (self *SuggestionsContext) RefreshSuggestions() { self.State.AsyncHandler.Do(func() func() { findSuggestionsFn := self.State.FindSuggestions if findSuggestionsFn != nil { suggestions := findSuggestionsFn(self.c.GetPromptInput()) return func() { self.SetSuggestions(suggestions) } } else { return func() {} } }) } // There is currently no need to use range-select in the suggestions view so we're disabling it. func (self *SuggestionsContext) RangeSelectEnabled() bool { return false } lazygit-0.50.0+ds1/pkg/gui/context/tags_context.go000066400000000000000000000032531500612110400220150ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type TagsContext struct { *FilteredListViewModel[*models.Tag] *ListContextTrait } var ( _ types.IListContext = (*TagsContext)(nil) _ types.DiffableContext = (*TagsContext)(nil) ) func NewTagsContext( c *ContextCommon, ) *TagsContext { viewModel := NewFilteredListViewModel( func() []*models.Tag { return c.Model().Tags }, func(tag *models.Tag) []string { return []string{tag.Name, tag.Message} }, ) getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetTagListDisplayStrings( viewModel.GetItems(), c.State().GetItemOperation, c.Modes().Diffing.Ref, c.Tr, c.UserConfig()) } return &TagsContext{ FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Tags, WindowName: "branches", Key: TAGS_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } } func (self *TagsContext) GetSelectedRef() types.Ref { tag := self.GetSelected() if tag == nil { return nil } return tag } func (self *TagsContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() return []string{itemId} } func (self *TagsContext) RefForAdjustingLineNumberInDiff() string { return self.GetSelectedItemId() } func (self *TagsContext) ShowBranchHeadsInSubCommits() bool { return true } lazygit-0.50.0+ds1/pkg/gui/context/traits/000077500000000000000000000000001500612110400202675ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/context/traits/list_cursor.go000066400000000000000000000130521500612110400231670ustar00rootroot00000000000000package traits import ( "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type RangeSelectMode int const ( // None means we are not selecting a range RangeSelectModeNone RangeSelectMode = iota // Sticky range select is started by pressing 'v', then the range is expanded // when you move up or down. It is cancelled by pressing 'v' again. RangeSelectModeSticky // Nonsticky range select is started by pressing shift+arrow and cancelled // when pressing up/down without shift, or by pressing 'v' RangeSelectModeNonSticky ) type ListCursor struct { selectedIdx int rangeSelectMode RangeSelectMode // value is ignored when rangeSelectMode is RangeSelectModeNone rangeStartIdx int // Get the length of the list. We use this to clamp the selection so that // the selected index is always valid getLength func() int } func NewListCursor(getLength func() int) *ListCursor { return &ListCursor{ selectedIdx: 0, rangeStartIdx: 0, rangeSelectMode: RangeSelectModeNone, getLength: getLength, } } var _ types.IListCursor = (*ListCursor)(nil) func (self *ListCursor) GetSelectedLineIdx() int { return self.selectedIdx } // Sets the selected line index. Note, you probably don't want to use this directly, // because it doesn't affect the range select mode or range start index. You should only // use this for navigation situations where e.g. the user wants to jump to the top of // a list while in range select mode so that the selection ends up being between // the top of the list and the previous selection func (self *ListCursor) SetSelectedLineIdx(value int) { self.selectedIdx = self.clampValue(value) } // Sets the selected index and cancels the range. You almost always want to use // this instead of SetSelectedLineIdx. For example, if you want to jump the cursor // to the top of a list after checking out a branch, you should use this method, // or you may end up with a large range selection from the previous cursor position // to the top of the list. func (self *ListCursor) SetSelection(value int) { self.selectedIdx = self.clampValue(value) self.CancelRangeSelect() } func (self *ListCursor) SetSelectionRangeAndMode(selectedIdx, rangeStartIdx int, mode RangeSelectMode) { self.selectedIdx = self.clampValue(selectedIdx) self.rangeStartIdx = self.clampValue(rangeStartIdx) if mode == RangeSelectModeNonSticky && selectedIdx == rangeStartIdx { self.rangeSelectMode = RangeSelectModeNone } else { self.rangeSelectMode = mode } } // Returns the selectedIdx, the rangeStartIdx, and the mode of the current selection. func (self *ListCursor) GetSelectionRangeAndMode() (int, int, RangeSelectMode) { if self.IsSelectingRange() { return self.selectedIdx, self.rangeStartIdx, self.rangeSelectMode } else { return self.selectedIdx, self.selectedIdx, self.rangeSelectMode } } func (self *ListCursor) clampValue(value int) int { clampedValue := -1 length := self.getLength() if length > 0 { clampedValue = lo.Clamp(value, 0, length-1) } return clampedValue } // Moves the cursor up or down by the given amount. // If we are in non-sticky range select mode, this will cancel the range select func (self *ListCursor) MoveSelectedLine(change int) { if self.rangeSelectMode == RangeSelectModeNonSticky { self.CancelRangeSelect() } self.SetSelectedLineIdx(self.selectedIdx + change) } // Moves the cursor up or down by the given amount, and also moves the range start // index by the same amount func (self *ListCursor) MoveSelection(delta int) { self.selectedIdx = self.clampValue(self.selectedIdx + delta) if self.IsSelectingRange() { self.rangeStartIdx = self.clampValue(self.rangeStartIdx + delta) } } // To be called when the model might have shrunk so that our selection is not out of bounds func (self *ListCursor) ClampSelection() { self.selectedIdx = self.clampValue(self.selectedIdx) self.rangeStartIdx = self.clampValue(self.rangeStartIdx) } func (self *ListCursor) Len() int { // The length of the model slice can change at any time, so the selection may // become out of bounds. To reduce the likelihood of this, we clamp the selection // whenever we obtain the length of the model. self.ClampSelection() return self.getLength() } func (self *ListCursor) GetRangeStartIdx() (int, bool) { if self.IsSelectingRange() { return self.rangeStartIdx, true } return 0, false } func (self *ListCursor) CancelRangeSelect() { self.rangeSelectMode = RangeSelectModeNone } // Returns true if we are in range select mode. Note that we may be in range select // mode and still only selecting a single item. See AreMultipleItemsSelected below. func (self *ListCursor) IsSelectingRange() bool { return self.rangeSelectMode != RangeSelectModeNone } // Returns true if we are in range select mode and selecting multiple items func (self *ListCursor) AreMultipleItemsSelected() bool { startIdx, endIdx := self.GetSelectionRange() return startIdx != endIdx } func (self *ListCursor) GetSelectionRange() (int, int) { if self.IsSelectingRange() { return utils.SortRange(self.selectedIdx, self.rangeStartIdx) } return self.selectedIdx, self.selectedIdx } func (self *ListCursor) ToggleStickyRange() { if self.IsSelectingRange() { self.CancelRangeSelect() } else { self.rangeStartIdx = self.selectedIdx self.rangeSelectMode = RangeSelectModeSticky } } func (self *ListCursor) ExpandNonStickyRange(change int) { if !self.IsSelectingRange() { self.rangeStartIdx = self.selectedIdx } self.rangeSelectMode = RangeSelectModeNonSticky self.SetSelectedLineIdx(self.selectedIdx + change) } lazygit-0.50.0+ds1/pkg/gui/context/view_trait.go000066400000000000000000000045341500612110400214730ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) const HORIZONTAL_SCROLL_FACTOR = 3 type ViewTrait struct { view *gocui.View } var _ types.IViewTrait = &ViewTrait{} func NewViewTrait(view *gocui.View) *ViewTrait { return &ViewTrait{view: view} } func (self *ViewTrait) FocusPoint(yIdx int) { self.view.FocusPoint(self.view.OriginX(), yIdx) } func (self *ViewTrait) SetRangeSelectStart(yIdx int) { self.view.SetRangeSelectStart(yIdx) } func (self *ViewTrait) CancelRangeSelect() { self.view.CancelRangeSelect() } func (self *ViewTrait) SetViewPortContent(content string) { _, y := self.view.Origin() self.view.OverwriteLines(y, content) } func (self *ViewTrait) SetViewPortContentAndClearEverythingElse(content string) { _, y := self.view.Origin() self.view.OverwriteLinesAndClearEverythingElse(y, content) } func (self *ViewTrait) SetContentLineCount(lineCount int) { self.view.SetContentLineCount(lineCount) } func (self *ViewTrait) SetContent(content string) { self.view.SetContent(content) } func (self *ViewTrait) SetHighlight(highlight bool) { self.view.Highlight = highlight self.view.HighlightInactive = false } func (self *ViewTrait) SetFooter(value string) { self.view.Footer = value } func (self *ViewTrait) SetOriginX(value int) { self.view.SetOriginX(value) } // tells us the start of line indexes shown in the view currently as well as the capacity of lines shown in the viewport. func (self *ViewTrait) ViewPortYBounds() (int, int) { _, start := self.view.Origin() length := self.view.InnerHeight() return start, length } func (self *ViewTrait) ScrollLeft() { self.view.ScrollLeft(self.horizontalScrollAmount()) } func (self *ViewTrait) ScrollRight() { self.view.ScrollRight(self.horizontalScrollAmount()) } func (self *ViewTrait) horizontalScrollAmount() int { return self.view.InnerWidth() / HORIZONTAL_SCROLL_FACTOR } func (self *ViewTrait) ScrollUp(value int) { self.view.ScrollUp(value) } func (self *ViewTrait) ScrollDown(value int) { self.view.ScrollDown(value) } // this returns the amount we'll scroll if we want to scroll by a page. func (self *ViewTrait) PageDelta() int { height := self.view.InnerHeight() delta := height - 1 if delta == 0 { return 1 } return delta } func (self *ViewTrait) SelectedLineIdx() int { return self.view.SelectedLineIdx() } lazygit-0.50.0+ds1/pkg/gui/context/working_tree_context.go000066400000000000000000000036251500612110400235610ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type WorkingTreeContext struct { *filetree.FileTreeViewModel *ListContextTrait *SearchTrait } var ( _ types.IListContext = (*WorkingTreeContext)(nil) _ types.ISearchableContext = (*WorkingTreeContext)(nil) ) func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { viewModel := filetree.NewFileTreeViewModel( func() []*models.File { return c.Model().Files }, c.Log, c.UserConfig().Gui.ShowFileTree, ) getDisplayStrings := func(_ int, _ int) [][]string { showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons showNumstat := c.UserConfig().Gui.ShowNumstatInFilesView lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons) return lo.Map(lines, func(line string, _ int) []string { return []string{line} }) } ctx := &WorkingTreeContext{ SearchTrait: NewSearchTrait(c), FileTreeViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Files, WindowName: "files", Key: FILES_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect)) return ctx } func (self *WorkingTreeContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { return nil } lazygit-0.50.0+ds1/pkg/gui/context/worktrees_context.go000066400000000000000000000022701500612110400231020ustar00rootroot00000000000000package context import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type WorktreesContext struct { *FilteredListViewModel[*models.Worktree] *ListContextTrait } var _ types.IListContext = (*WorktreesContext)(nil) func NewWorktreesContext(c *ContextCommon) *WorktreesContext { viewModel := NewFilteredListViewModel( func() []*models.Worktree { return c.Model().Worktrees }, func(Worktree *models.Worktree) []string { return []string{Worktree.Name} }, ) getDisplayStrings := func(_ int, _ int) [][]string { return presentation.GetWorktreeDisplayStrings( c.Tr, viewModel.GetFilteredList(), ) } return &WorktreesContext{ FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Worktrees, WindowName: "files", Key: WORKTREES_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, })), ListRenderer: ListRenderer{ list: viewModel, getDisplayStrings: getDisplayStrings, }, c: c, }, } } lazygit-0.50.0+ds1/pkg/gui/context_config.go000066400000000000000000000014011500612110400206310ustar00rootroot00000000000000package gui import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) func (gui *Gui) contextTree() *context.ContextTree { contextCommon := &context.ContextCommon{ IGuiCommon: gui.c.IGuiCommon, Common: gui.c.Common, } return context.NewContextTree(contextCommon) } // using this wrapper for when an onFocus function doesn't care about any potential // props that could be passed func OnFocusWrapper(f func() error) func(opts types.OnFocusOpts) error { return func(opts types.OnFocusOpts) error { return f() } } func (gui *Gui) defaultSideContext() types.Context { if gui.State.Modes.Filtering.Active() { return gui.State.Contexts.LocalCommits } else { return gui.State.Contexts.Files } } lazygit-0.50.0+ds1/pkg/gui/controllers.go000066400000000000000000000366151500612110400202050ustar00rootroot00000000000000package gui import ( "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/controllers" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands" "github.com/jesseduffield/lazygit/pkg/gui/status" "github.com/jesseduffield/lazygit/pkg/gui/types" ) func (gui *Gui) Helpers() *helpers.Helpers { return gui.helpers } // Note, the order of controllers determines the order in which keybindings appear // in the keybinding menu: the earlier that the controller is attached to a context, // the lower in the list the keybindings will appear. func (gui *Gui) resetHelpersAndControllers() { for _, context := range gui.Contexts().Flatten() { context.ClearAllBindingsFn() } helperCommon := gui.c recordDirectoryHelper := helpers.NewRecordDirectoryHelper(helperCommon) reposHelper := helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onNewRepo) rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon) refsHelper := helpers.NewRefsHelper(helperCommon, rebaseHelper) suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon) worktreeHelper := helpers.NewWorktreeHelper(helperCommon, reposHelper, refsHelper, suggestionsHelper) setCommitSummary := gui.getCommitMessageSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitMessage }) setCommitDescription := gui.getCommitMessageSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitDescription }) getCommitSummary := func() string { return strings.TrimSpace(gui.Views.CommitMessage.TextArea.GetContent()) } getCommitDescription := func() string { return strings.TrimSpace(gui.Views.CommitDescription.TextArea.GetContent()) } getUnwrappedCommitDescription := func() string { return strings.TrimSpace(gui.Views.CommitDescription.TextArea.GetUnwrappedContent()) } commitsHelper := helpers.NewCommitsHelper(helperCommon, getCommitSummary, setCommitSummary, getCommitDescription, getUnwrappedCommitDescription, setCommitDescription, ) gpgHelper := helpers.NewGpgHelper(helperCommon) viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts) patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon) stagingHelper := helpers.NewStagingHelper(helperCommon) mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon) searchHelper := helpers.NewSearchHelper(helperCommon) refreshHelper := helpers.NewRefreshHelper( helperCommon, refsHelper, rebaseHelper, patchBuildingHelper, stagingHelper, mergeConflictsHelper, worktreeHelper, searchHelper, ) diffHelper := helpers.NewDiffHelper(helperCommon) cherryPickHelper := helpers.NewCherryPickHelper( helperCommon, rebaseHelper, ) bisectHelper := helpers.NewBisectHelper(helperCommon) windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper) modeHelper := helpers.NewModeHelper( helperCommon, diffHelper, patchBuildingHelper, cherryPickHelper, rebaseHelper, bisectHelper, ) appStatusHelper := helpers.NewAppStatusHelper( helperCommon, func() *status.StatusManager { return gui.statusManager }, modeHelper, ) setSubCommits := func(commits []*models.Commit) { gui.Mutexes.SubCommitsMutex.Lock() defer gui.Mutexes.SubCommitsMutex.Unlock() gui.State.Model.SubCommits = commits } gui.helpers = &helpers.Helpers{ Refs: refsHelper, Host: helpers.NewHostHelper(helperCommon), PatchBuilding: patchBuildingHelper, Staging: stagingHelper, Bisect: bisectHelper, Suggestions: suggestionsHelper, Files: helpers.NewFilesHelper(helperCommon), WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, refsHelper, commitsHelper, gpgHelper), Tags: helpers.NewTagsHelper(helperCommon, commitsHelper, gpgHelper), BranchesHelper: helpers.NewBranchesHelper(helperCommon, worktreeHelper), GPG: helpers.NewGpgHelper(helperCommon), MergeAndRebase: rebaseHelper, MergeConflicts: mergeConflictsHelper, CherryPick: cherryPickHelper, Upstream: helpers.NewUpstreamHelper(helperCommon, suggestionsHelper.GetRemoteBranchesSuggestionsFunc), AmendHelper: helpers.NewAmendHelper(helperCommon, gpgHelper), FixupHelper: helpers.NewFixupHelper(helperCommon), Commits: commitsHelper, Snake: helpers.NewSnakeHelper(helperCommon), Diff: diffHelper, Repos: reposHelper, RecordDirectory: recordDirectoryHelper, Update: helpers.NewUpdateHelper(helperCommon, gui.Updater), Window: windowHelper, View: viewHelper, Refresh: refreshHelper, Confirmation: helpers.NewConfirmationHelper(helperCommon), Mode: modeHelper, AppStatus: appStatusHelper, InlineStatus: helpers.NewInlineStatusHelper(helperCommon, windowHelper), WindowArrangement: helpers.NewWindowArrangementHelper( gui.c, windowHelper, modeHelper, appStatusHelper, ), Search: searchHelper, Worktree: worktreeHelper, SubCommits: helpers.NewSubCommitsHelper(helperCommon, refreshHelper, setSubCommits), } gui.CustomCommandsClient = custom_commands.NewClient( helperCommon, gui.helpers, ) common := controllers.NewControllerCommon(helperCommon, gui) syncController := controllers.NewSyncController( common, ) submodulesController := controllers.NewSubmodulesController(common) bisectController := controllers.NewBisectController(common) commitMessageController := controllers.NewCommitMessageController( common, ) commitDescriptionController := controllers.NewCommitDescriptionController( common, ) remoteBranchesController := controllers.NewRemoteBranchesController(common) menuController := controllers.NewMenuController(common) localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull) tagsController := controllers.NewTagsController(common) filesController := controllers.NewFilesController( common, ) mergeConflictsController := controllers.NewMergeConflictsController(common) remotesController := controllers.NewRemotesController( common, func(branches []*models.RemoteBranch) { gui.State.Model.RemoteBranches = branches }, ) worktreesController := controllers.NewWorktreesController(common) undoController := controllers.NewUndoController(common) globalController := controllers.NewGlobalController(common) contextLinesController := controllers.NewContextLinesController(common) renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common) verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common) viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common) branchesController := controllers.NewBranchesController(common) gitFlowController := controllers.NewGitFlowController(common) stashController := controllers.NewStashController(common) commitFilesController := controllers.NewCommitFilesController(common) patchExplorerControllerFactory := controllers.NewPatchExplorerControllerFactory(common) stagingController := controllers.NewStagingController(common, gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, false) stagingSecondaryController := controllers.NewStagingController(common, gui.State.Contexts.StagingSecondary, gui.State.Contexts.Staging, true) mainViewController := controllers.NewMainViewController(common, gui.State.Contexts.Normal, gui.State.Contexts.NormalSecondary) secondaryViewController := controllers.NewMainViewController(common, gui.State.Contexts.NormalSecondary, gui.State.Contexts.Normal) patchBuildingController := controllers.NewPatchBuildingController(common) snakeController := controllers.NewSnakeController(common) reflogCommitsController := controllers.NewReflogCommitsController(common) subCommitsController := controllers.NewSubCommitsController(common) statusController := controllers.NewStatusController(common) commandLogController := controllers.NewCommandLogController(common) confirmationController := controllers.NewConfirmationController(common) suggestionsController := controllers.NewSuggestionsController(common) jumpToSideWindowController := controllers.NewJumpToSideWindowController(common, gui.handleNextTab) sideWindowControllerFactory := controllers.NewSideWindowControllerFactory(common) filterControllerFactory := controllers.NewFilterControllerFactory(common) for _, context := range gui.c.Context().AllFilterable() { controllers.AttachControllers(context, filterControllerFactory.Create(context)) } searchControllerFactory := controllers.NewSearchControllerFactory(common) for _, context := range gui.c.Context().AllSearchable() { controllers.AttachControllers(context, searchControllerFactory.Create(context)) } for _, context := range []controllers.CanViewWorktreeOptions{ gui.State.Contexts.LocalCommits, gui.State.Contexts.ReflogCommits, gui.State.Contexts.SubCommits, gui.State.Contexts.Stash, gui.State.Contexts.Branches, gui.State.Contexts.RemoteBranches, gui.State.Contexts.Tags, } { controllers.AttachControllers(context, controllers.NewWorktreeOptionsController(common, context)) } // allow for navigating between side window contexts for _, context := range []types.Context{ gui.State.Contexts.Status, gui.State.Contexts.Remotes, gui.State.Contexts.Worktrees, gui.State.Contexts.Tags, gui.State.Contexts.Branches, gui.State.Contexts.RemoteBranches, gui.State.Contexts.Files, gui.State.Contexts.Submodules, gui.State.Contexts.ReflogCommits, gui.State.Contexts.LocalCommits, gui.State.Contexts.CommitFiles, gui.State.Contexts.SubCommits, gui.State.Contexts.Stash, } { controllers.AttachControllers(context, sideWindowControllerFactory.Create(context)) } for _, context := range []controllers.CanSwitchToSubCommits{ gui.State.Contexts.Branches, gui.State.Contexts.RemoteBranches, gui.State.Contexts.Tags, gui.State.Contexts.ReflogCommits, } { controllers.AttachControllers(context, controllers.NewSwitchToSubCommitsController( common, context, )) } for _, context := range []controllers.CanSwitchToDiffFiles{ gui.State.Contexts.LocalCommits, gui.State.Contexts.SubCommits, gui.State.Contexts.Stash, } { controllers.AttachControllers(context, controllers.NewSwitchToDiffFilesController( common, context, )) } for _, context := range []types.Context{ gui.State.Contexts.Files, gui.State.Contexts.Branches, gui.State.Contexts.RemoteBranches, gui.State.Contexts.Tags, gui.State.Contexts.LocalCommits, gui.State.Contexts.ReflogCommits, gui.State.Contexts.SubCommits, gui.State.Contexts.CommitFiles, gui.State.Contexts.Stash, } { controllers.AttachControllers(context, controllers.NewSwitchToFocusedMainViewController( common, context, )) } for _, context := range []controllers.ContainsCommits{ gui.State.Contexts.LocalCommits, gui.State.Contexts.ReflogCommits, gui.State.Contexts.SubCommits, } { controllers.AttachControllers(context, controllers.NewBasicCommitsController(common, context)) } controllers.AttachControllers(gui.State.Contexts.ReflogCommits, reflogCommitsController, ) controllers.AttachControllers(gui.State.Contexts.SubCommits, subCommitsController, ) // TODO: add scroll controllers for main panels (need to bring some more functionality across for that e.g. reading more from the currently displayed git command) controllers.AttachControllers(gui.State.Contexts.Staging, stagingController, patchExplorerControllerFactory.Create(gui.State.Contexts.Staging), verticalScrollControllerFactory.Create(gui.State.Contexts.Staging), ) controllers.AttachControllers(gui.State.Contexts.StagingSecondary, stagingSecondaryController, patchExplorerControllerFactory.Create(gui.State.Contexts.StagingSecondary), verticalScrollControllerFactory.Create(gui.State.Contexts.StagingSecondary), ) controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilder, patchBuildingController, patchExplorerControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder), verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder), ) controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilderSecondary, verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilderSecondary), ) controllers.AttachControllers(gui.State.Contexts.MergeConflicts, mergeConflictsController, ) controllers.AttachControllers(gui.State.Contexts.Normal, mainViewController, verticalScrollControllerFactory.Create(gui.State.Contexts.Normal), viewSelectionControllerFactory.Create(gui.State.Contexts.Normal), ) controllers.AttachControllers(gui.State.Contexts.NormalSecondary, secondaryViewController, verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary), viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary), ) controllers.AttachControllers(gui.State.Contexts.Files, filesController, ) controllers.AttachControllers(gui.State.Contexts.Tags, tagsController, ) controllers.AttachControllers(gui.State.Contexts.Submodules, submodulesController, ) controllers.AttachControllers(gui.State.Contexts.Branches, branchesController, gitFlowController, ) controllers.AttachControllers(gui.State.Contexts.LocalCommits, localCommitsController, bisectController, ) controllers.AttachControllers(gui.State.Contexts.CommitFiles, commitFilesController, ) controllers.AttachControllers(gui.State.Contexts.Remotes, remotesController, ) controllers.AttachControllers(gui.State.Contexts.Worktrees, worktreesController, ) controllers.AttachControllers(gui.State.Contexts.Stash, stashController, ) controllers.AttachControllers(gui.State.Contexts.Menu, menuController, ) controllers.AttachControllers(gui.State.Contexts.CommitMessage, commitMessageController, ) controllers.AttachControllers(gui.State.Contexts.CommitDescription, commitDescriptionController, verticalScrollControllerFactory.Create(gui.State.Contexts.CommitDescription), ) controllers.AttachControllers(gui.State.Contexts.RemoteBranches, remoteBranchesController, ) controllers.AttachControllers(gui.State.Contexts.Status, statusController, ) controllers.AttachControllers(gui.State.Contexts.CommandLog, commandLogController, ) controllers.AttachControllers(gui.State.Contexts.Confirmation, confirmationController, ) controllers.AttachControllers(gui.State.Contexts.Suggestions, suggestionsController, ) controllers.AttachControllers(gui.State.Contexts.Search, controllers.NewSearchPromptController(common), ) controllers.AttachControllers(gui.State.Contexts.Global, undoController, globalController, contextLinesController, renameSimilarityThresholdController, jumpToSideWindowController, syncController, ) controllers.AttachControllers(gui.State.Contexts.Snake, snakeController, ) // this must come last so that we've got our click handlers defined against the context listControllerFactory := controllers.NewListControllerFactory(common) for _, context := range gui.c.Context().AllList() { controllers.AttachControllers(context, listControllerFactory.Create(context)) } } func (gui *Gui) getCommitMessageSetTextareaTextFn(getView func() *gocui.View) func(string) { return func(text string) { // using a getView function so that we don't need to worry about when the view is created view := getView() view.ClearTextArea() view.TextArea.TypeString(text) view.RenderTextArea() } } lazygit-0.50.0+ds1/pkg/gui/controllers/000077500000000000000000000000001500612110400176435ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/controllers/attach.go000066400000000000000000000011511500612110400214340ustar00rootroot00000000000000package controllers import "github.com/jesseduffield/lazygit/pkg/gui/types" func AttachControllers(context types.Context, controllers ...types.IController) { for _, controller := range controllers { context.AddKeybindingsFn(controller.GetKeybindings) context.AddMouseKeybindingsFn(controller.GetMouseKeybindings) context.AddOnClickFn(controller.GetOnClick()) context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView()) context.AddOnRenderToMainFn(controller.GetOnRenderToMain()) context.AddOnFocusFn(controller.GetOnFocus()) context.AddOnFocusLostFn(controller.GetOnFocusLost()) } } lazygit-0.50.0+ds1/pkg/gui/controllers/base_controller.go000066400000000000000000000014521500612110400233510ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type baseController struct{} func (self *baseController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return nil } func (self *baseController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return nil } func (self *baseController) GetOnClick() func() error { return nil } func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return nil } func (self *baseController) GetOnRenderToMain() func() { return nil } func (self *baseController) GetOnFocus() func(types.OnFocusOpts) { return nil } func (self *baseController) GetOnFocusLost() func(types.OnFocusLostOpts) { return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/basic_commits_controller.go000066400000000000000000000327251500612110400252620ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // This controller is for all contexts that contain a list of commits. var _ types.IController = &BasicCommitsController{} type ContainsCommits interface { types.Context types.IListContext GetSelected() *models.Commit GetSelectedItems() ([]*models.Commit, int, int) GetCommits() []*models.Commit GetSelectedLineIdx() int GetSelectionRangeAndMode() (int, int, traits.RangeSelectMode) SetSelectionRangeAndMode(int, int, traits.RangeSelectMode) } type BasicCommitsController struct { baseController *ListControllerTrait[*models.Commit] c *ControllerCommon context ContainsCommits } func NewBasicCommitsController(c *ControllerCommon, context ContainsCommits) *BasicCommitsController { return &BasicCommitsController{ baseController: baseController{}, c: c, context: context, ListControllerTrait: NewListControllerTrait( c, context, context.GetSelected, context.GetSelectedItems, ), } } func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Commits.CheckoutCommit), Handler: self.withItem(self.checkout), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Checkout, Tooltip: self.c.Tr.CheckoutCommitTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.CopyCommitAttributeToClipboard), Handler: self.withItem(self.copyCommitAttribute), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CopyCommitAttributeToClipboard, Tooltip: self.c.Tr.CopyCommitAttributeToClipboardTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Commits.OpenInBrowser), Handler: self.withItem(self.openInBrowser), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenCommitInBrowser, }, { Key: opts.GetKey(opts.Config.Universal.New), Handler: self.withItem(self.newBranch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreateNewBranchFromCommit, }, { // Putting this in BasicCommitsController even though we really only want it in the commits // panel. But I find it important that this ends up next to "New Branch", and I couldn't // find another way to achieve this. It's not such a big deal to have it in subcommits and // reflog too, I'd say. Key: opts.GetKey(opts.Config.Branches.MoveCommitsToNewBranch), Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch, GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch, Description: self.c.Tr.MoveCommitsToNewBranch, Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, }, { Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewResetOptions, Tooltip: self.c.Tr.ResetTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.CherryPickCopy), Handler: self.withItem(self.copyRange), GetDisabledReason: self.require(self.itemRangeSelected(self.canCopyCommits)), Description: self.c.Tr.CherryPickCopy, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.CherryPickCopyTooltip, map[string]string{ "paste": keybindings.Label(opts.Config.Commits.PasteCommits), "escape": keybindings.Label(opts.Config.Universal.Return), }, ), DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.ResetCherryPick), Handler: self.c.Helpers().CherryPick.Reset, Description: self.c.Tr.ResetCherryPick, }, { Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(self.openDiffTool), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, { Key: opts.GetKey(opts.Config.Commits.SelectCommitsOfCurrentBranch), Handler: self.selectCommitsOfCurrentBranch, GetDisabledReason: self.require(self.canSelectCommitsOfCurrentBranch), Description: self.c.Tr.SelectCommitsOfCurrentBranch, }, // Putting this at the bottom of the list so that it has the lowest priority, // meaning that if the user has configured another keybinding to the same key // then that will take precedence. { // Hardcoding this key because it's not configurable Key: opts.GetKey("c"), Handler: self.handleOldCherryPickKey, }, } return bindings } func (self *BasicCommitsController) getCommitMessageBody(hash string) string { commitMessageBody, err := self.c.Git().Commit.GetCommitMessage(hash) if err != nil { return "" } _, body := self.c.Helpers().Commits.SplitCommitMessageAndDescription(commitMessageBody) return body } func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) error { commitMessageBody := self.getCommitMessageBody(commit.Hash()) var commitMessageBodyDisabled *types.DisabledReason if commitMessageBody == "" { commitMessageBodyDisabled = &types.DisabledReason{ Text: self.c.Tr.CommitHasNoMessageBody, } } items := []*types.MenuItem{ { Label: self.c.Tr.CommitHash, OnPress: func() error { return self.copyCommitHashToClipboard(commit) }, }, { Label: self.c.Tr.CommitSubject, OnPress: func() error { return self.copyCommitSubjectToClipboard(commit) }, Key: 's', }, { Label: self.c.Tr.CommitMessage, OnPress: func() error { return self.copyCommitMessageToClipboard(commit) }, Key: 'm', }, { Label: self.c.Tr.CommitMessageBody, DisabledReason: commitMessageBodyDisabled, OnPress: func() error { return self.copyCommitMessageBodyToClipboard(commitMessageBody) }, Key: 'b', }, { Label: self.c.Tr.CommitURL, OnPress: func() error { return self.copyCommitURLToClipboard(commit) }, Key: 'u', }, { Label: self.c.Tr.CommitDiff, OnPress: func() error { return self.copyCommitDiffToClipboard(commit) }, Key: 'd', }, { Label: self.c.Tr.CommitAuthor, OnPress: func() error { return self.copyAuthorToClipboard(commit) }, Key: 'a', }, } commitTagsItem := types.MenuItem{ Label: self.c.Tr.CommitTags, OnPress: func() error { return self.copyCommitTagsToClipboard(commit) }, Key: 't', } if len(commit.Tags) == 0 { commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags} } items = append(items, &commitTagsItem) return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard, Items: items, }) } func (self *BasicCommitsController) copyCommitHashToClipboard(commit *models.Commit) error { self.c.LogAction(self.c.Tr.Actions.CopyCommitHashToClipboard) if err := self.c.OS().CopyToClipboard(commit.Hash()); err != nil { return err } self.c.Toast(fmt.Sprintf("'%s' %s", commit.Hash(), self.c.Tr.CopiedToClipboard)) return nil } func (self *BasicCommitsController) copyCommitURLToClipboard(commit *models.Commit) error { url, err := self.c.Helpers().Host.GetCommitURL(commit.Hash()) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.CopyCommitURLToClipboard) if err := self.c.OS().CopyToClipboard(url); err != nil { return err } self.c.Toast(self.c.Tr.CommitURLCopiedToClipboard) return nil } func (self *BasicCommitsController) copyCommitDiffToClipboard(commit *models.Commit) error { diff, err := self.c.Git().Commit.GetCommitDiff(commit.Hash()) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.CopyCommitDiffToClipboard) if err := self.c.OS().CopyToClipboard(diff); err != nil { return err } self.c.Toast(self.c.Tr.CommitDiffCopiedToClipboard) return nil } func (self *BasicCommitsController) copyAuthorToClipboard(commit *models.Commit) error { author, err := self.c.Git().Commit.GetCommitAuthor(commit.Hash()) if err != nil { return err } formattedAuthor := fmt.Sprintf("%s <%s>", author.Name, author.Email) self.c.LogAction(self.c.Tr.Actions.CopyCommitAuthorToClipboard) if err := self.c.OS().CopyToClipboard(formattedAuthor); err != nil { return err } self.c.Toast(self.c.Tr.CommitAuthorCopiedToClipboard) return nil } func (self *BasicCommitsController) copyCommitMessageToClipboard(commit *models.Commit) error { message, err := self.c.Git().Commit.GetCommitMessage(commit.Hash()) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.CopyCommitMessageToClipboard) if err := self.c.OS().CopyToClipboard(message); err != nil { return err } self.c.Toast(self.c.Tr.CommitMessageCopiedToClipboard) return nil } func (self *BasicCommitsController) copyCommitMessageBodyToClipboard(commitMessageBody string) error { self.c.LogAction(self.c.Tr.Actions.CopyCommitMessageBodyToClipboard) if err := self.c.OS().CopyToClipboard(commitMessageBody); err != nil { return err } self.c.Toast(self.c.Tr.CommitMessageBodyCopiedToClipboard) return nil } func (self *BasicCommitsController) copyCommitSubjectToClipboard(commit *models.Commit) error { message, err := self.c.Git().Commit.GetCommitSubject(commit.Hash()) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.CopyCommitSubjectToClipboard) if err := self.c.OS().CopyToClipboard(message); err != nil { return err } self.c.Toast(self.c.Tr.CommitSubjectCopiedToClipboard) return nil } func (self *BasicCommitsController) copyCommitTagsToClipboard(commit *models.Commit) error { message := strings.Join(commit.Tags, "\n") self.c.LogAction(self.c.Tr.Actions.CopyCommitTagsToClipboard) if err := self.c.OS().CopyToClipboard(message); err != nil { return err } self.c.Toast(self.c.Tr.CommitTagsCopiedToClipboard) return nil } func (self *BasicCommitsController) openInBrowser(commit *models.Commit) error { url, err := self.c.Helpers().Host.GetCommitURL(commit.Hash()) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.OpenCommitInBrowser) if err := self.c.OS().OpenLink(url); err != nil { return err } return nil } func (self *BasicCommitsController) newBranch(commit *models.Commit) error { return self.c.Helpers().Refs.NewBranch(commit.RefName(), commit.Description(), "") } func (self *BasicCommitsController) createResetMenu(commit *models.Commit) error { return self.c.Helpers().Refs.CreateGitResetMenu(commit.Hash()) } func (self *BasicCommitsController) checkout(commit *models.Commit) error { return self.c.Helpers().Refs.CreateCheckoutMenu(commit) } func (self *BasicCommitsController) copyRange(*models.Commit) error { return self.c.Helpers().CherryPick.CopyRange(self.context.GetCommits(), self.context) } func (self *BasicCommitsController) canCopyCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { for _, commit := range selectedCommits { if commit.Hash() == "" { return &types.DisabledReason{Text: self.c.Tr.CannotCherryPickNonCommit, ShowErrorInPanel: true} } } return nil } func (self *BasicCommitsController) handleOldCherryPickKey() error { msg := utils.ResolvePlaceholderString(self.c.Tr.OldCherryPickKeyWarning, map[string]string{ "copy": keybindings.Label(self.c.UserConfig().Keybinding.Commits.CherryPickCopy), "paste": keybindings.Label(self.c.UserConfig().Keybinding.Commits.PasteCommits), }) return errors.New(msg) } func (self *BasicCommitsController) openDiffTool(commit *models.Commit) error { to := commit.RefName() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(commit.ParentRefName()) _, err := self.c.RunSubprocess(self.c.Git().Diff.OpenDiffToolCmdObj( git_commands.DiffToolCmdOptions{ Filepath: ".", FromCommit: from, ToCommit: to, Reverse: reverse, IsDirectory: true, Staged: false, })) return err } func (self *BasicCommitsController) canSelectCommitsOfCurrentBranch() *types.DisabledReason { if index := self.findFirstCommitAfterCurrentBranch(); index <= 0 { return &types.DisabledReason{Text: self.c.Tr.NoCommitsThisBranch} } return nil } func (self *BasicCommitsController) findFirstCommitAfterCurrentBranch() int { _, index, ok := lo.FindIndexOf(self.context.GetCommits(), func(c *models.Commit) bool { return c.IsMerge() || c.Status == models.StatusMerged }) if !ok { return 0 } return index } func (self *BasicCommitsController) selectCommitsOfCurrentBranch() error { index := self.findFirstCommitAfterCurrentBranch() if index <= 0 { return nil } _, _, mode := self.context.GetSelectionRangeAndMode() if mode != traits.RangeSelectModeSticky { // If we are in sticky range mode already, keep that; otherwise, open a non-sticky range mode = traits.RangeSelectModeNonSticky } // Create the range from bottom to top, so that when you cancel the range, // the head commit is selected self.context.SetSelectionRangeAndMode(0, index-1, mode) self.context.HandleFocus(types.OnFocusOpts{}) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/bisect_controller.go000066400000000000000000000224001500612110400237040ustar00rootroot00000000000000package controllers import ( "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type BisectController struct { baseController *ListControllerTrait[*models.Commit] c *ControllerCommon } var _ types.IController = &BisectController{} func NewBisectController( c *ControllerCommon, ) *BisectController { return &BisectController{ baseController: baseController{}, c: c, ListControllerTrait: NewListControllerTrait( c, c.Contexts().LocalCommits, c.Contexts().LocalCommits.GetSelected, c.Contexts().LocalCommits.GetSelectedItems, ), } } func (self *BisectController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Commits.ViewBisectOptions), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.openMenu)), Description: self.c.Tr.ViewBisectOptions, OpensMenu: true, }, } return bindings } func (self *BisectController) openMenu(commit *models.Commit) error { // no shame in getting this directly rather than using the cached value // given how cheap it is to obtain info := self.c.Git().Bisect.GetInfo() if info.Started() { return self.openMidBisectMenu(info, commit) } else { return self.openStartBisectMenu(info, commit) } } func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error { // if there is not yet a 'current' bisect commit, or if we have // selected the current commit, we need to jump to the next 'current' commit // after we perform a bisect action. The reason we don't unconditionally jump // is that sometimes the user will want to go and mark a few commits as skipped // in a row and they wouldn't want to be jumped back to the current bisect // commit each time. // Originally we were allowing the user to, from the bisect menu, select whether // they were talking about the selected commit or the current bisect commit, // and that was a bit confusing (and required extra keypresses). selectCurrentAfter := info.GetCurrentHash() == "" || info.GetCurrentHash() == commit.Hash() // we need to wait to reselect if our bisect commits aren't ancestors of our 'start' // ref, because we'll be reloading our commits in that case. waitToReselect := selectCurrentAfter && !self.c.Git().Bisect.ReachableFromStart(info) // If we have a current hash already, then we always want to use that one. If // not, we're still picking the initial commits before we really start, so // use the selected commit in that case. bisecting := info.GetCurrentHash() != "" hashToMark := lo.Ternary(bisecting, info.GetCurrentHash(), commit.Hash()) shortHashToMark := utils.ShortHash(hashToMark) // For marking a commit as bad, when we're not already bisecting, we require // a single item selected, but once we are bisecting, it doesn't matter because // the action applies to the HEAD commit rather than the selected commit. var singleItemIfNotBisecting *types.DisabledReason if !bisecting { singleItemIfNotBisecting = self.require(self.singleItemSelected())() } menuItems := []*types.MenuItem{ { Label: fmt.Sprintf(self.c.Tr.Bisect.Mark, shortHashToMark, info.NewTerm()), OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.BisectMark) if err := self.c.Git().Bisect.Mark(hashToMark, info.NewTerm()); err != nil { return err } return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, Key: 'b', }, { Label: fmt.Sprintf(self.c.Tr.Bisect.Mark, shortHashToMark, info.OldTerm()), OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.BisectMark) if err := self.c.Git().Bisect.Mark(hashToMark, info.OldTerm()); err != nil { return err } return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, Key: 'g', }, { Label: fmt.Sprintf(self.c.Tr.Bisect.SkipCurrent, shortHashToMark), OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.BisectSkip) if err := self.c.Git().Bisect.Skip(hashToMark); err != nil { return err } return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, Key: 's', }, } if info.GetCurrentHash() != "" && info.GetCurrentHash() != commit.Hash() { menuItems = append(menuItems, lo.ToPtr(types.MenuItem{ Label: fmt.Sprintf(self.c.Tr.Bisect.SkipSelected, commit.ShortHash()), OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.BisectSkip) if err := self.c.Git().Bisect.Skip(commit.Hash()); err != nil { return err } return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'S', })) } menuItems = append(menuItems, lo.ToPtr(types.MenuItem{ Label: self.c.Tr.Bisect.ResetOption, OnPress: func() error { return self.c.Helpers().Bisect.Reset() }, Key: 'r', })) return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Bisect.BisectMenuTitle, Items: menuItems, }) } func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Bisect.BisectMenuTitle, Items: []*types.MenuItem{ { Label: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortHash(), info.NewTerm()), OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.StartBisect) if err := self.c.Git().Bisect.Start(); err != nil { return err } if err := self.c.Git().Bisect.Mark(commit.Hash(), info.NewTerm()); err != nil { return err } return self.c.Helpers().Bisect.PostBisectCommandRefresh() }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'b', }, { Label: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortHash(), info.OldTerm()), OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.StartBisect) if err := self.c.Git().Bisect.Start(); err != nil { return err } if err := self.c.Git().Bisect.Mark(commit.Hash(), info.OldTerm()); err != nil { return err } return self.c.Helpers().Bisect.PostBisectCommandRefresh() }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'g', }, { Label: self.c.Tr.Bisect.ChooseTerms, OnPress: func() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.Bisect.OldTermPrompt, HandleConfirm: func(oldTerm string) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.Bisect.NewTermPrompt, HandleConfirm: func(newTerm string) error { self.c.LogAction(self.c.Tr.Actions.StartBisect) if err := self.c.Git().Bisect.StartWithTerms(oldTerm, newTerm); err != nil { return err } return self.c.Helpers().Bisect.PostBisectCommandRefresh() }, }) return nil }, }) return nil }, Key: 't', }, }, }) } func (self *BisectController) showBisectCompleteMessage(candidateHashes []string) error { prompt := self.c.Tr.Bisect.CompletePrompt if len(candidateHashes) > 1 { prompt = self.c.Tr.Bisect.CompletePromptIndeterminate } formattedCommits, err := self.c.Git().Commit.GetCommitsOneline(candidateHashes) if err != nil { return err } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Bisect.CompleteTitle, Prompt: fmt.Sprintf(prompt, strings.TrimSpace(formattedCommits)), HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.ResetBisect) if err := self.c.Git().Bisect.Reset(); err != nil { return err } return self.c.Helpers().Bisect.PostBisectCommandRefresh() }, }) return nil } func (self *BisectController) afterMark(selectCurrent bool, waitToReselect bool) error { done, candidateHashes, err := self.c.Git().Bisect.IsDone() if err != nil { return err } if err := self.afterBisectMarkRefresh(selectCurrent, waitToReselect); err != nil { return err } if done { return self.showBisectCompleteMessage(candidateHashes) } return nil } func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error { selectFn := func() error { if selectCurrent { self.selectCurrentBisectCommit() } return nil } if waitToReselect { return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{}, Then: selectFn}) } else { _ = selectFn() return self.c.Helpers().Bisect.PostBisectCommandRefresh() } } func (self *BisectController) selectCurrentBisectCommit() { info := self.c.Git().Bisect.GetInfo() if info.GetCurrentHash() != "" { // find index of commit with that hash, move cursor to that. for i, commit := range self.c.Model().Commits { if commit.Hash() == info.GetCurrentHash() { self.context().SetSelection(i) self.context().HandleFocus(types.OnFocusOpts{}) break } } } } func (self *BisectController) context() *context.LocalCommitsContext { return self.c.Contexts().LocalCommits } lazygit-0.50.0+ds1/pkg/gui/controllers/branches_controller.go000066400000000000000000000672551500612110400242410ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type BranchesController struct { baseController *ListControllerTrait[*models.Branch] c *ControllerCommon } var _ types.IController = &BranchesController{} func NewBranchesController( c *ControllerCommon, ) *BranchesController { return &BranchesController{ baseController: baseController{}, c: c, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Branches, c.Contexts().Branches.GetSelected, c.Contexts().Branches.GetSelectedItems, ), } } func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItem(self.press), GetDisabledReason: self.require( self.singleItemSelected(), self.notPulling, ), Description: self.c.Tr.Checkout, Tooltip: self.c.Tr.CheckoutTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.New), Handler: self.withItem(self.newBranch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewBranch, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.MoveCommitsToNewBranch), Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch, GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch, Description: self.c.Tr.MoveCommitsToNewBranch, Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, }, { Key: opts.GetKey(opts.Config.Branches.CreatePullRequest), Handler: self.withItem(self.handleCreatePullRequest), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreatePullRequest, }, { Key: opts.GetKey(opts.Config.Branches.ViewPullRequestOptions), Handler: self.withItem(self.handleCreatePullRequestMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreatePullRequestOptions, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Branches.CopyPullRequestURL), Handler: self.copyPullRequestURL, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CopyPullRequestURL, }, { Key: opts.GetKey(opts.Config.Branches.CheckoutBranchByName), Handler: self.checkoutByName, Description: self.c.Tr.CheckoutByName, Tooltip: self.c.Tr.CheckoutByNameTooltip, }, { Key: opts.GetKey(opts.Config.Branches.ForceCheckoutBranch), Handler: self.forceCheckout, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ForceCheckout, Tooltip: self.c.Tr.ForceCheckoutTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItems(self.delete), GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)), Description: self.c.Tr.Delete, Tooltip: self.c.Tr.BranchDeleteTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.RebaseBranch), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.RebaseBranch, Tooltip: self.c.Tr.RebaseBranchTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch), Handler: opts.Guards.OutsideFilterMode(self.merge), GetDisabledReason: self.require(self.singleItemSelected(self.notMergingIntoYourself)), Description: self.c.Tr.Merge, Tooltip: self.c.Tr.MergeBranchTooltip, DisplayOnScreen: true, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Branches.FastForward), Handler: self.withItem(self.fastForward), GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)), Description: self.c.Tr.FastForward, Tooltip: self.c.Tr.FastForwardTooltip, }, { Key: opts.GetKey(opts.Config.Branches.CreateTag), Handler: self.withItem(self.createTag), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewTag, }, { Key: opts.GetKey(opts.Config.Branches.SortOrder), Handler: self.createSortMenu, Description: self.c.Tr.SortOrder, }, { Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewResetOptions, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.RenameBranch), Handler: self.withItem(self.rename), GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)), Description: self.c.Tr.RenameBranch, }, { Key: opts.GetKey(opts.Config.Branches.SetUpstream), Handler: self.withItem(self.viewUpstreamOptions), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewBranchUpstreamOptions, Tooltip: self.c.Tr.ViewBranchUpstreamOptionsTooltip, ShortDescription: self.c.Tr.Upstream, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(func(selectedBranch *models.Branch) error { return self.c.Helpers().Diff.OpenDiffToolForRef(selectedBranch) }), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, } } func (self *BranchesController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { var task types.UpdateTask branch := self.context().GetSelected() if branch == nil { task = types.NewRenderStringTask(self.c.Tr.NoBranchesThisRepo) } else { cmdObj := self.c.Git().Branch.GetGraphCmdObj(branch.FullRefName()) task = types.NewRunPtyTask(cmdObj.GetCmd()) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: self.c.Tr.LogTitle, Task: task, }, }) }) } } func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branch) error { upstream := lo.Ternary(selectedBranch.RemoteBranchStoredLocally(), selectedBranch.ShortUpstreamRefName(), self.c.Tr.UpstreamGenericName) viewDivergenceItem := &types.MenuItem{ LabelColumns: []string{self.c.Tr.ViewDivergenceFromUpstream}, OnPress: func() error { branch := self.context().GetSelected() if branch == nil { return nil } return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{ Ref: branch, TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), upstream), RefToShowDivergenceFrom: branch.FullUpstreamRefName(), Context: self.context(), ShowBranchHeads: false, }) }, } var disabledReason *types.DisabledReason baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(selectedBranch, self.c.Model().MainBranches) if err != nil { return err } if baseBranch == "" { baseBranch = self.c.Tr.CouldNotDetermineBaseBranch disabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} } shortBaseBranchName := helpers.ShortBranchName(baseBranch) label := utils.ResolvePlaceholderString( self.c.Tr.ViewDivergenceFromBaseBranch, map[string]string{"baseBranch": shortBaseBranchName}, ) viewDivergenceFromBaseBranchItem := &types.MenuItem{ LabelColumns: []string{label}, Key: 'b', OnPress: func() error { branch := self.context().GetSelected() if branch == nil { return nil } return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{ Ref: branch, TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), shortBaseBranchName), RefToShowDivergenceFrom: baseBranch, Context: self.context(), ShowBranchHeads: false, }) }, DisabledReason: disabledReason, } unsetUpstreamItem := &types.MenuItem{ LabelColumns: []string{self.c.Tr.UnsetUpstream}, OnPress: func() error { if err := self.c.Git().Branch.UnsetUpstream(selectedBranch.Name); err != nil { return err } if err := self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{ types.BRANCHES, types.COMMITS, }, }); err != nil { return err } return nil }, Key: 'u', } setUpstreamItem := &types.MenuItem{ LabelColumns: []string{self.c.Tr.SetUpstream}, OnPress: func() error { return self.c.Helpers().Upstream.PromptForUpstreamWithoutInitialContent(selectedBranch, func(upstream string) error { upstreamRemote, upstreamBranch, err := self.c.Helpers().Upstream.ParseUpstream(upstream) if err != nil { return err } if err := self.c.Git().Branch.SetUpstream(upstreamRemote, upstreamBranch, selectedBranch.Name); err != nil { return err } if err := self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{ types.BRANCHES, types.COMMITS, }, }); err != nil { return err } return nil }) }, Key: 's', } upstreamResetOptions := utils.ResolvePlaceholderString( self.c.Tr.ViewUpstreamResetOptions, map[string]string{"upstream": upstream}, ) upstreamResetTooltip := utils.ResolvePlaceholderString( self.c.Tr.ViewUpstreamResetOptionsTooltip, map[string]string{"upstream": upstream}, ) upstreamRebaseOptions := utils.ResolvePlaceholderString( self.c.Tr.ViewUpstreamRebaseOptions, map[string]string{"upstream": upstream}, ) upstreamRebaseTooltip := utils.ResolvePlaceholderString( self.c.Tr.ViewUpstreamRebaseOptionsTooltip, map[string]string{"upstream": upstream}, ) upstreamResetItem := &types.MenuItem{ LabelColumns: []string{upstreamResetOptions}, OpensMenu: true, OnPress: func() error { err := self.c.Helpers().Refs.CreateGitResetMenu(upstream) if err != nil { return err } return nil }, Tooltip: upstreamResetTooltip, Key: 'g', } upstreamRebaseItem := &types.MenuItem{ LabelColumns: []string{upstreamRebaseOptions}, OpensMenu: true, OnPress: func() error { if err := self.c.Helpers().MergeAndRebase.RebaseOntoRef(upstream); err != nil { return err } return nil }, Tooltip: upstreamRebaseTooltip, Key: 'r', } if !selectedBranch.IsTrackingRemote() { unsetUpstreamItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} } if !selectedBranch.RemoteBranchStoredLocally() { viewDivergenceItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} upstreamResetItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} upstreamRebaseItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} } options := []*types.MenuItem{ viewDivergenceItem, viewDivergenceFromBaseBranchItem, unsetUpstreamItem, setUpstreamItem, upstreamResetItem, upstreamRebaseItem, } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.BranchUpstreamOptionsTitle, Items: options, }) } func (self *BranchesController) Context() types.Context { return self.context() } func (self *BranchesController) context() *context.BranchesContext { return self.c.Contexts().Branches } func (self *BranchesController) press(selectedBranch *models.Branch) error { if selectedBranch == self.c.Helpers().Refs.GetCheckedOutRef() { return errors.New(self.c.Tr.AlreadyCheckedOutBranch) } worktreeForRef, ok := self.worktreeForBranch(selectedBranch) if ok && !worktreeForRef.IsCurrent { return self.promptToCheckoutWorktree(worktreeForRef) } self.c.LogAction(self.c.Tr.Actions.CheckoutBranch) return self.c.Helpers().Refs.CheckoutRef(selectedBranch.Name, types.CheckoutRefOptions{}) } func (self *BranchesController) notPulling() *types.DisabledReason { currentBranch := self.c.Helpers().Refs.GetCheckedOutRef() if currentBranch != nil { op := self.c.State().GetItemOperation(currentBranch) if op == types.ItemOperationFastForwarding || op == types.ItemOperationPulling { return &types.DisabledReason{Text: self.c.Tr.CantCheckoutBranchWhilePulling} } } return nil } func (self *BranchesController) worktreeForBranch(branch *models.Branch) (*models.Worktree, bool) { return git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees) } func (self *BranchesController) promptToCheckoutWorktree(worktree *models.Worktree) error { prompt := utils.ResolvePlaceholderString(self.c.Tr.AlreadyCheckedOutByWorktree, map[string]string{ "worktreeName": worktree.Name, }) self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.SwitchToWorktree, Prompt: prompt, HandleConfirm: func() error { return self.c.Helpers().Worktree.Switch(worktree, context.LOCAL_BRANCHES_CONTEXT_KEY) }, }) return nil } func (self *BranchesController) handleCreatePullRequest(selectedBranch *models.Branch) error { if !selectedBranch.IsTrackingRemote() { return errors.New(self.c.Tr.PullRequestNoUpstream) } return self.createPullRequest(selectedBranch.UpstreamBranch, "") } func (self *BranchesController) handleCreatePullRequestMenu(selectedBranch *models.Branch) error { checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef() return self.createPullRequestMenu(selectedBranch, checkedOutBranch) } func (self *BranchesController) copyPullRequestURL() error { branch := self.context().GetSelected() branchExistsOnRemote := self.c.Git().Remote.CheckRemoteBranchExists(branch.Name) if !branchExistsOnRemote { return errors.New(self.c.Tr.NoBranchOnRemote) } url, err := self.c.Helpers().Host.GetPullRequestURL(branch.Name, "") if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.CopyPullRequestURL) if err := self.c.OS().CopyToClipboard(url); err != nil { return err } self.c.Toast(self.c.Tr.PullRequestURLCopiedToClipboard) return nil } func (self *BranchesController) forceCheckout() error { branch := self.context().GetSelected() message := self.c.Tr.SureForceCheckout title := self.c.Tr.ForceCheckoutBranch self.c.Confirm(types.ConfirmOpts{ Title: title, Prompt: message, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.ForceCheckoutBranch) if err := self.c.Git().Branch.Checkout(branch.Name, git_commands.CheckoutOptions{Force: true}); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }, }) return nil } func (self *BranchesController) checkoutByName() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.BranchName + ":", FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRefsSuggestionsFunc(), HandleConfirm: func(response string) error { self.c.LogAction("Checkout branch") _, branchName, found := self.c.Helpers().Refs.ParseRemoteBranchName(response) if found { return self.c.Helpers().Refs.CheckoutRemoteBranch(response, branchName) } return self.c.Helpers().Refs.CheckoutRef(response, types.CheckoutRefOptions{ OnRefNotFound: func(ref string) error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.BranchNotFoundTitle, Prompt: fmt.Sprintf("%s %s%s", self.c.Tr.BranchNotFoundPrompt, ref, "?"), HandleConfirm: func() error { return self.createNewBranchWithName(ref) }, }) return nil }, }) }, }, ) return nil } func (self *BranchesController) createNewBranchWithName(newBranchName string) error { branch := self.context().GetSelected() if branch == nil { return nil } if err := self.c.Git().Branch.New(newBranchName, branch.FullRefName()); err != nil { return err } self.context().SetSelection(0) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, KeepBranchSelectionIndex: true}) } func (self *BranchesController) localDelete(branches []*models.Branch) error { return self.c.Helpers().BranchesHelper.ConfirmLocalDelete(branches) } func (self *BranchesController) remoteDelete(branches []*models.Branch) error { remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch { return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} }) return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(remoteBranches) } func (self *BranchesController) localAndRemoteDelete(branches []*models.Branch) error { return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branches) } func (self *BranchesController) delete(branches []*models.Branch) error { checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef() isBranchCheckedOut := lo.SomeBy(branches, func(branch *models.Branch) bool { return checkedOutBranch.Name == branch.Name }) hasUpstream := lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.IsTrackingRemote() && !branch.UpstreamGone }) localDeleteItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalBranches, self.c.Tr.DeleteLocalBranch), Key: 'c', OnPress: func() error { return self.localDelete(branches) }, } if isBranchCheckedOut { localDeleteItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch} } remoteDeleteItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteRemoteBranches, self.c.Tr.DeleteRemoteBranch), Key: 'r', OnPress: func() error { return self.remoteDelete(branches) }, } if !hasUpstream { remoteDeleteItem.DisabledReason = &types.DisabledReason{ Text: lo.Ternary(len(branches) > 1, self.c.Tr.UpstreamsNotSetError, self.c.Tr.UpstreamNotSetError), } } deleteBothItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalAndRemoteBranches, self.c.Tr.DeleteLocalAndRemoteBranch), Key: 'b', OnPress: func() error { return self.localAndRemoteDelete(branches) }, } if isBranchCheckedOut { deleteBothItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch} } else if !hasUpstream { deleteBothItem.DisabledReason = &types.DisabledReason{ Text: lo.Ternary(len(branches) > 1, self.c.Tr.UpstreamsNotSetError, self.c.Tr.UpstreamNotSetError), } } var menuTitle string if len(branches) == 1 { menuTitle = utils.ResolvePlaceholderString( self.c.Tr.DeleteBranchTitle, map[string]string{ "selectedBranchName": branches[0].Name, }, ) } else { menuTitle = self.c.Tr.DeleteBranchesTitle } return self.c.Menu(types.CreateMenuOptions{ Title: menuTitle, Items: []*types.MenuItem{localDeleteItem, remoteDeleteItem, deleteBothItem}, }) } func (self *BranchesController) merge() error { selectedBranchName := self.context().GetSelected().Name return self.c.Helpers().MergeAndRebase.MergeRefIntoCheckedOutBranch(selectedBranchName) } func (self *BranchesController) rebase(branch *models.Branch) error { return self.c.Helpers().MergeAndRebase.RebaseOntoRef(branch.Name) } func (self *BranchesController) fastForward(branch *models.Branch) error { if !branch.IsTrackingRemote() { return errors.New(self.c.Tr.FwdNoUpstream) } if !branch.RemoteBranchStoredLocally() { return errors.New(self.c.Tr.FwdNoLocalUpstream) } if branch.IsAheadForPull() { return errors.New(self.c.Tr.FwdCommitsToPush) } action := self.c.Tr.Actions.FastForwardBranch return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { worktree, ok := self.worktreeForBranch(branch) if ok { self.c.LogAction(action) worktreeGitDir := "" worktreePath := "" // if it is the current worktree path, no need to specify the path if !worktree.IsCurrent { worktreeGitDir = worktree.GitDir worktreePath = worktree.Path } err := self.c.Git().Sync.Pull( task, git_commands.PullOptions{ RemoteName: branch.UpstreamRemote, BranchName: branch.UpstreamBranch, FastForwardOnly: true, WorktreeGitDir: worktreeGitDir, WorktreePath: worktreePath, }, ) _ = self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) return err } else { self.c.LogAction(action) err := self.c.Git().Sync.FastForward( task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch, ) _ = self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return err } }) } func (self *BranchesController) createTag(branch *models.Branch) error { return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {}) } func (self *BranchesController) createSortMenu() error { return self.c.Helpers().Refs.CreateSortOrderMenu([]string{"recency", "alphabetical", "date"}, func(sortOrder string) error { if self.c.GetAppState().LocalBranchSortOrder != sortOrder { self.c.GetAppState().LocalBranchSortOrder = sortOrder self.c.SaveAppStateAndLogError() self.c.Contexts().Branches.SetSelection(0) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) } return nil }, self.c.GetAppState().LocalBranchSortOrder) } func (self *BranchesController) createResetMenu(selectedBranch *models.Branch) error { return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.Name) } func (self *BranchesController) rename(branch *models.Branch) error { promptForNewName := func() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewBranchNamePrompt + " " + branch.Name + ":", InitialContent: branch.Name, HandleConfirm: func(newBranchName string) error { self.c.LogAction(self.c.Tr.Actions.RenameBranch) if err := self.c.Git().Branch.Rename(branch.Name, helpers.SanitizedBranchName(newBranchName)); err != nil { return err } // need to find where the branch is now so that we can re-select it. That means we need to refetch the branches synchronously and then find our branch _ = self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES}, }) // now that we've got our stuff again we need to find that branch and reselect it. for i, newBranch := range self.c.Model().Branches { if newBranch.Name == newBranchName { self.context().SetSelection(i) self.context().HandleRender() } } return nil }, }) return nil } // I could do an explicit check here for whether the branch is tracking a remote branch // but if we've selected it we'll already know that via Pullables and Pullables. // Bit of a hack but I'm lazy. if !branch.IsTrackingRemote() { return promptForNewName() } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.RenameBranch, Prompt: self.c.Tr.RenameBranchWarning, HandleConfirm: promptForNewName, }) return nil } func (self *BranchesController) newBranch(selectedBranch *models.Branch) error { return self.c.Helpers().Refs.NewBranch(selectedBranch.FullRefName(), selectedBranch.RefName(), "") } func (self *BranchesController) createPullRequestMenu(selectedBranch *models.Branch, checkedOutBranch *models.Branch) error { menuItems := make([]*types.MenuItem, 0, 4) fromToLabelColumns := func(from string, to string) []string { return []string{fmt.Sprintf("%s → %s", from, to)} } menuItemsForBranch := func(branch *models.Branch) []*types.MenuItem { return []*types.MenuItem{ { LabelColumns: fromToLabelColumns(branch.Name, self.c.Tr.DefaultBranch), OnPress: func() error { return self.handleCreatePullRequest(branch) }, }, { LabelColumns: fromToLabelColumns(branch.Name, self.c.Tr.SelectBranch), OnPress: func() error { if !branch.IsTrackingRemote() { return errors.New(self.c.Tr.PullRequestNoUpstream) } if len(self.c.Model().Remotes) == 1 { toRemote := self.c.Model().Remotes[0].Name self.c.Log.Debugf("PR will target the only existing remote '%s'", toRemote) return self.promptForTargetBranchNameAndCreatePullRequest(branch, toRemote) } self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.SelectTargetRemote, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(), HandleConfirm: func(toRemote string) error { self.c.Log.Debugf("PR will target remote '%s'", toRemote) return self.promptForTargetBranchNameAndCreatePullRequest(branch, toRemote) }, }) return nil }, }, } } if selectedBranch != checkedOutBranch { menuItems = append(menuItems, &types.MenuItem{ LabelColumns: fromToLabelColumns(checkedOutBranch.Name, selectedBranch.Name), OnPress: func() error { if !checkedOutBranch.IsTrackingRemote() || !selectedBranch.IsTrackingRemote() { return errors.New(self.c.Tr.PullRequestNoUpstream) } return self.createPullRequest(checkedOutBranch.UpstreamBranch, selectedBranch.UpstreamBranch) }, }, ) menuItems = append(menuItems, menuItemsForBranch(checkedOutBranch)...) } menuItems = append(menuItems, menuItemsForBranch(selectedBranch)...) return self.c.Menu(types.CreateMenuOptions{Title: fmt.Sprint(self.c.Tr.CreatePullRequestOptions), Items: menuItems}) } func (self *BranchesController) promptForTargetBranchNameAndCreatePullRequest(fromBranch *models.Branch, toRemote string) error { remoteDoesNotExist := lo.NoneBy(self.c.Model().Remotes, func(remote *models.Remote) bool { return remote.Name == toRemote }) if remoteDoesNotExist { return fmt.Errorf(self.c.Tr.NoValidRemoteName, toRemote) } self.c.Prompt(types.PromptOpts{ Title: fmt.Sprintf("%s → %s/", fromBranch.UpstreamBranch, toRemote), FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteBranchesForRemoteSuggestionsFunc(toRemote), HandleConfirm: func(toBranch string) error { self.c.Log.Debugf("PR will target branch '%s' on remote '%s'", toBranch, toRemote) return self.createPullRequest(fromBranch.UpstreamBranch, toBranch) }, }) return nil } func (self *BranchesController) createPullRequest(from string, to string) error { url, err := self.c.Helpers().Host.GetPullRequestURL(from, to) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.OpenPullRequest) if err := self.c.OS().OpenLink(url); err != nil { return err } return nil } func (self *BranchesController) branchIsReal(branch *models.Branch) *types.DisabledReason { if !branch.IsRealBranch() { return &types.DisabledReason{Text: self.c.Tr.SelectedItemIsNotABranch} } return nil } func (self *BranchesController) branchesAreReal(selectedBranches []*models.Branch, startIdx int, endIdx int) *types.DisabledReason { if !lo.EveryBy(selectedBranches, func(branch *models.Branch) bool { return branch.IsRealBranch() }) { return &types.DisabledReason{Text: self.c.Tr.SelectedItemIsNotABranch} } return nil } func (self *BranchesController) notMergingIntoYourself(branch *models.Branch) *types.DisabledReason { selectedBranchName := branch.Name checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef().Name if checkedOutBranch == selectedBranchName { return &types.DisabledReason{Text: self.c.Tr.CantMergeBranchIntoItself} } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/command_log_controller.go000066400000000000000000000015621500612110400247200ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/types" ) type CommandLogController struct { baseController c *ControllerCommon } var _ types.IController = &CommandLogController{} func NewCommandLogController( c *ControllerCommon, ) *CommandLogController { return &CommandLogController{ baseController: baseController{}, c: c, } } func (self *CommandLogController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{} return bindings } func (self *CommandLogController) GetOnFocusLost() func(types.OnFocusLostOpts) { return func(types.OnFocusLostOpts) { self.c.Views().Extras.Autoscroll = true } } func (self *CommandLogController) Context() types.Context { return self.context() } func (self *CommandLogController) context() types.Context { return self.c.Contexts().CommandLog } lazygit-0.50.0+ds1/pkg/gui/controllers/commit_description_controller.go000066400000000000000000000076461500612110400263450ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type CommitDescriptionController struct { baseController c *ControllerCommon } var _ types.IController = &CommitMessageController{} func NewCommitDescriptionController( c *ControllerCommon, ) *CommitDescriptionController { return &CommitDescriptionController{ baseController: baseController{}, c: c, } } func (self *CommitDescriptionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.TogglePanel), Handler: self.handleTogglePanel, }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: self.close, }, { Key: opts.GetKey(opts.Config.Universal.ConfirmInEditor), Handler: self.confirm, }, { Key: opts.GetKey(opts.Config.CommitMessage.CommitMenu), Handler: self.openCommitMenu, }, } return bindings } func (self *CommitDescriptionController) Context() types.Context { return self.c.Contexts().CommitDescription } func (self *CommitDescriptionController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.Context().GetViewName(), Key: gocui.MouseLeft, Handler: self.onClick, }, } } func (self *CommitDescriptionController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { self.c.Views().CommitDescription.Footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter, map[string]string{ "confirmInEditorKeybinding": keybindings.Label(self.c.UserConfig().Keybinding.Universal.ConfirmInEditor), }) } } func (self *CommitDescriptionController) switchToCommitMessage() error { self.c.Context().Replace(self.c.Contexts().CommitMessage) return nil } func (self *CommitDescriptionController) handleTogglePanel() error { // The default keybinding for this action is "", which means that we // also get here when pasting multi-line text that contains tabs. In that // case we don't want to toggle the panel, but insert the tab as a character // (somehow, see below). // // Only do this if the TogglePanel command is actually mapped to "" // (the default). If it's not, we can only hope that it's mapped to some // ctrl key or fn key, which is unlikely to occur in pasted text. And if // they mapped some *other* command to "", then we're totally out of // luck. if self.c.GocuiGui().IsPasting && self.c.UserConfig().Keybinding.Universal.TogglePanel == "" { // Handling tabs in pasted commit messages is not optimal, but hopefully // good enough for now. We simply insert 4 spaces without worrying about // column alignment. This works well enough for leading indentation, // which is common in pasted code snippets. view := self.Context().GetView() for range 4 { view.Editor.Edit(view, gocui.KeySpace, ' ', 0) } return nil } return self.switchToCommitMessage() } func (self *CommitDescriptionController) close() error { self.c.Helpers().Commits.CloseCommitMessagePanel() return nil } func (self *CommitDescriptionController) confirm() error { return self.c.Helpers().Commits.HandleCommitConfirm() } func (self *CommitDescriptionController) openCommitMenu() error { authorSuggestion := self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc() return self.c.Helpers().Commits.OpenCommitMenu(authorSuggestion) } func (self *CommitDescriptionController) onClick(opts gocui.ViewMouseBindingOpts) error { // Activate the description panel when the commit message panel is currently active if self.c.Context().Current().GetKey() == context.COMMIT_MESSAGE_CONTEXT_KEY { self.c.Context().Replace(self.c.Contexts().CommitDescription) } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/commit_message_controller.go000066400000000000000000000150561500612110400254400ustar00rootroot00000000000000package controllers import ( "errors" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type CommitMessageController struct { baseController c *ControllerCommon } var _ types.IController = &CommitMessageController{} func NewCommitMessageController( c *ControllerCommon, ) *CommitMessageController { return &CommitMessageController{ baseController: baseController{}, c: c, } } func (self *CommitMessageController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.SubmitEditorText), Handler: self.confirm, Description: self.c.Tr.Confirm, }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: self.close, Description: self.c.Tr.Close, }, { Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.handlePreviousCommit, }, { Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.handleNextCommit, }, { Key: opts.GetKey(opts.Config.Universal.TogglePanel), Handler: self.handleTogglePanel, }, { Key: opts.GetKey(opts.Config.CommitMessage.CommitMenu), Handler: self.openCommitMenu, }, } return bindings } func (self *CommitMessageController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.Context().GetViewName(), Key: gocui.MouseLeft, Handler: self.onClick, }, } } func (self *CommitMessageController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { self.c.Views().CommitDescription.Footer = "" } } func (self *CommitMessageController) GetOnFocusLost() func(types.OnFocusLostOpts) { return func(types.OnFocusLostOpts) { self.context().RenderSubtitle() } } func (self *CommitMessageController) Context() types.Context { return self.context() } func (self *CommitMessageController) context() *context.CommitMessageContext { return self.c.Contexts().CommitMessage } func (self *CommitMessageController) handlePreviousCommit() error { return self.handleCommitIndexChange(1) } func (self *CommitMessageController) handleNextCommit() error { if self.context().GetSelectedIndex() == context.NoCommitIndex { return nil } return self.handleCommitIndexChange(-1) } func (self *CommitMessageController) switchToCommitDescription() error { self.c.Context().Replace(self.c.Contexts().CommitDescription) return nil } func (self *CommitMessageController) handleTogglePanel() error { // The default keybinding for this action is "", which means that we // also get here when pasting multi-line text that contains tabs. In that // case we don't want to toggle the panel, but insert the tab as a character // (somehow, see below). // // Only do this if the TogglePanel command is actually mapped to "" // (the default). If it's not, we can only hope that it's mapped to some // ctrl key or fn key, which is unlikely to occur in pasted text. And if // they mapped some *other* command to "", then we're totally out of // luck. if self.c.GocuiGui().IsPasting && self.c.UserConfig().Keybinding.Universal.TogglePanel == "" { // It is unlikely that a pasted commit message contains a tab in the // subject line, so it shouldn't matter too much how we handle it. // Simply insert 4 spaces instead; all that matters is that we don't // switch to the description panel. view := self.context().GetView() for range 4 { view.Editor.Edit(view, gocui.KeySpace, ' ', 0) } return nil } return self.switchToCommitDescription() } func (self *CommitMessageController) handleCommitIndexChange(value int) error { currentIndex := self.context().GetSelectedIndex() newIndex := currentIndex + value if newIndex == context.NoCommitIndex { self.context().SetSelectedIndex(newIndex) self.c.Helpers().Commits.SetMessageAndDescriptionInView(self.context().GetHistoryMessage()) return nil } else if currentIndex == context.NoCommitIndex { self.context().SetHistoryMessage(self.c.Helpers().Commits.JoinCommitMessageAndUnwrappedDescription()) } validCommit, err := self.setCommitMessageAtIndex(newIndex) if validCommit { self.context().SetSelectedIndex(newIndex) } return err } // returns true if the given index is for a valid commit func (self *CommitMessageController) setCommitMessageAtIndex(index int) (bool, error) { commitMessage, err := self.c.Git().Commit.GetCommitMessageFromHistory(index) if err != nil { if err == git_commands.ErrInvalidCommitIndex { return false, nil } return false, errors.New(self.c.Tr.CommitWithoutMessageErr) } if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage { commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth) } self.c.Helpers().Commits.UpdateCommitPanelView(commitMessage) return true, nil } func (self *CommitMessageController) confirm() error { // The default keybinding for this action is "", which means that we // also get here when pasting multi-line text that contains newlines. In // that case we don't want to confirm the commit, but switch to the // description panel instead so that the rest of the pasted text goes there. // // Only do this if the SubmitEditorText command is actually mapped to // "" (the default). If it's not, we can only hope that it's mapped // to some ctrl key or fn key, which is unlikely to occur in pasted text. // And if they mapped some *other* command to "", then we're totally // out of luck. if self.c.GocuiGui().IsPasting && self.c.UserConfig().Keybinding.Universal.SubmitEditorText == "" { return self.switchToCommitDescription() } return self.c.Helpers().Commits.HandleCommitConfirm() } func (self *CommitMessageController) close() error { self.c.Helpers().Commits.CloseCommitMessagePanel() return nil } func (self *CommitMessageController) openCommitMenu() error { authorSuggestion := self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc() return self.c.Helpers().Commits.OpenCommitMenu(authorSuggestion) } func (self *CommitMessageController) onClick(opts gocui.ViewMouseBindingOpts) error { // Activate the commit message panel when the commit description panel is currently active if self.c.Context().Current().GetKey() == context.COMMIT_DESCRIPTION_CONTEXT_KEY { self.c.Context().Replace(self.c.Contexts().CommitMessage) } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/commits_files_controller.go000066400000000000000000000441671500612110400253060ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "path/filepath" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type CommitFilesController struct { baseController *ListControllerTrait[*filetree.CommitFileNode] c *ControllerCommon } var _ types.IController = &CommitFilesController{} func NewCommitFilesController( c *ControllerCommon, ) *CommitFilesController { return &CommitFilesController{ baseController: baseController{}, c: c, ListControllerTrait: NewListControllerTrait( c, c.Contexts().CommitFiles, c.Contexts().CommitFiles.GetSelected, c.Contexts().CommitFiles.GetSelectedItems, ), } } func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Files.CopyFileInfoToClipboard), Handler: self.openCopyMenu, Description: self.c.Tr.CopyToClipboardMenu, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.CommitFiles.CheckoutCommitFile), Handler: self.withItem(self.checkout), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Checkout, Tooltip: self.c.Tr.CheckoutCommitFileTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItems(self.discard), GetDisabledReason: self.require(self.itemsSelected()), Description: self.c.Tr.Remove, Tooltip: self.c.Tr.DiscardOldFileChangeTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenFile), Handler: self.withItem(self.open), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.withItems(self.edit), GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)), Description: self.c.Tr.Edit, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(self.openDiffTool), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItems(self.toggleForPatch), GetDisabledReason: self.require(self.itemsSelected()), Description: self.c.Tr.ToggleAddToPatch, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.ToggleAddToPatchTooltip, map[string]string{"doc": constants.Links.Docs.CustomPatchDemo}, ), DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.ToggleStagedAll), Handler: self.withItem(self.toggleAllForPatch), Description: self.c.Tr.ToggleAllInPatch, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.ToggleAllInPatchTooltip, map[string]string{"doc": constants.Links.Docs.CustomPatchDemo}, ), }, { Key: opts.GetKey(opts.Config.Universal.GoInto), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.EnterCommitFile, Tooltip: self.c.Tr.EnterCommitFileTooltip, }, { Key: opts.GetKey(opts.Config.Files.ToggleTreeView), Handler: self.toggleTreeView, Description: self.c.Tr.ToggleTreeView, Tooltip: self.c.Tr.ToggleTreeViewTooltip, }, { Key: opts.GetKey(opts.Config.Files.CollapseAll), Handler: self.collapseAll, Description: self.c.Tr.CollapseAll, Tooltip: self.c.Tr.CollapseAllTooltip, GetDisabledReason: self.require(self.isInTreeMode), }, { Key: opts.GetKey(opts.Config.Files.ExpandAll), Handler: self.expandAll, Description: self.c.Tr.ExpandAll, Tooltip: self.c.Tr.ExpandAllTooltip, GetDisabledReason: self.require(self.isInTreeMode), }, } return bindings } func (self *CommitFilesController) context() *context.CommitFilesContext { return self.c.Contexts().CommitFiles } func (self *CommitFilesController) GetOnRenderToMain() func() { return func() { node := self.context().GetSelected() if node == nil { return } from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false) task := types.NewRunPtyTask(cmdObj.GetCmd()) self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: self.c.Tr.Patch, SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: task, }, Secondary: secondaryPatchPanelUpdateOpts(self.c), }) } } func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage string) error { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, path, true) diff, err := cmdObj.RunWithOutput() if err != nil { return err } if err := self.c.OS().CopyToClipboard(diff); err != nil { return err } self.c.Toast(toastMessage) return nil } func (self *CommitFilesController) copyFileContentToClipboard(path string) error { _, to := self.context().GetFromAndToForDiff() cmdObj := self.c.Git().Commit.ShowFileContentCmdObj(to, path) diff, err := cmdObj.RunWithOutput() if err != nil { return err } return self.c.OS().CopyToClipboard(diff) } func (self *CommitFilesController) openCopyMenu() error { node := self.context().GetSelected() copyNameItem := &types.MenuItem{ Label: self.c.Tr.CopyFileName, OnPress: func() error { if err := self.c.OS().CopyToClipboard(node.Name()); err != nil { return err } self.c.Toast(self.c.Tr.FileNameCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'n', } copyRelativePathItem := &types.MenuItem{ Label: self.c.Tr.CopyRelativeFilePath, OnPress: func() error { if err := self.c.OS().CopyToClipboard(node.GetPath()); err != nil { return err } self.c.Toast(self.c.Tr.FilePathCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'p', } copyAbsolutePathItem := &types.MenuItem{ Label: self.c.Tr.CopyAbsoluteFilePath, OnPress: func() error { if err := self.c.OS().CopyToClipboard(filepath.Join(self.c.Git().RepoPaths.RepoPath(), node.GetPath())); err != nil { return err } self.c.Toast(self.c.Tr.FilePathCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'P', } copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, OnPress: func() error { return self.copyDiffToClipboard(node.GetPath(), self.c.Tr.FileDiffCopiedToast) }, DisabledReason: self.require(self.singleItemSelected())(), Key: 's', } copyAllDiff := &types.MenuItem{ Label: self.c.Tr.CopyAllFilesDiff, OnPress: func() error { return self.copyDiffToClipboard(".", self.c.Tr.AllFilesDiffCopiedToast) }, DisabledReason: self.require(self.itemsSelected())(), Key: 'a', } copyFileContentItem := &types.MenuItem{ Label: self.c.Tr.CopyFileContent, OnPress: func() error { if err := self.copyFileContentToClipboard(node.GetPath()); err != nil { return err } self.c.Toast(self.c.Tr.FileContentCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected( func(node *filetree.CommitFileNode) *types.DisabledReason { if !node.IsFile() { return &types.DisabledReason{ Text: self.c.Tr.ErrCannotCopyContentOfDirectory, ShowErrorInPanel: true, } } return nil }))(), Key: 'c', } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.CopyToClipboardMenu, Items: []*types.MenuItem{ copyNameItem, copyRelativePathItem, copyAbsolutePathItem, copyFileDiffItem, copyAllDiff, copyFileContentItem, }, }) } func (self *CommitFilesController) checkout(node *filetree.CommitFileNode) error { self.c.LogAction(self.c.Tr.Actions.CheckoutFile) _, to := self.context().GetFromAndToForDiff() if err := self.c.Git().WorkingTree.CheckoutFile(to, node.GetPath()); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) } func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileNode) error { parentContext := self.c.Context().Current().GetParentContext() if parentContext == nil || parentContext.GetKey() != context.LOCAL_COMMITS_CONTEXT_KEY { return errors.New(self.c.Tr.CanOnlyDiscardFromLocalCommits) } if ok, err := self.c.Helpers().PatchBuilding.ValidateNormalWorkingTreeState(); !ok { return err } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.DiscardFileChangesTitle, Prompt: self.c.Tr.DiscardFileChangesPrompt, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { var filePaths []string selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) // Reset the current patch if there is one. if self.c.Git().Patch.PatchBuilder.Active() { self.c.Git().Patch.PatchBuilder.Reset() if err := self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI}); err != nil { return err } } for _, node := range selectedNodes { err := node.ForEachFile(func(file *models.CommitFile) error { filePaths = append(filePaths, file.GetPath()) return nil }) if err != nil { return err } } err := self.c.Git().Rebase.DiscardOldFileChanges(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), filePaths) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } if self.context().RangeSelectEnabled() { self.context().GetList().CancelRangeSelect() } return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) }) }, }) return nil } func (self *CommitFilesController) open(node *filetree.CommitFileNode) error { return self.c.Helpers().Files.OpenFile(node.GetPath()) } func (self *CommitFilesController) edit(nodes []*filetree.CommitFileNode) error { return self.c.Helpers().Files.EditFiles(lo.FilterMap(nodes, func(node *filetree.CommitFileNode, _ int) (string, bool) { return node.GetPath(), node.IsFile() })) } func (self *CommitFilesController) canEditFiles(nodes []*filetree.CommitFileNode) *types.DisabledReason { if lo.NoneBy(nodes, func(node *filetree.CommitFileNode) bool { return node.IsFile() }) { return &types.DisabledReason{ Text: self.c.Tr.ErrCannotEditDirectory, ShowErrorInPanel: true, } } return nil } func (self *CommitFilesController) openDiffTool(node *filetree.CommitFileNode) error { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) _, err := self.c.RunSubprocess(self.c.Git().Diff.OpenDiffToolCmdObj( git_commands.DiffToolCmdOptions{ Filepath: node.GetPath(), FromCommit: from, ToCommit: to, Reverse: reverse, IsDirectory: !node.IsFile(), Staged: false, })) return err } func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.CommitFileNode) error { if self.c.AppState.DiffContextSize == 0 { return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage, keybindings.Label(self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)) } toggle := func() error { return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error { if !self.c.Git().Patch.PatchBuilder.Active() { if err := self.startPatchBuilder(); err != nil { return err } } selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) // Find if any file in the selection is unselected or partially added adding := lo.SomeBy(selectedNodes, func(node *filetree.CommitFileNode) bool { return node.SomeFile(func(file *models.CommitFile) bool { fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, self.context().GetRef().RefName()) return fileStatus == patch.PART || fileStatus == patch.UNSELECTED }) }) patchOperationFunction := self.c.Git().Patch.PatchBuilder.RemoveFile if adding { patchOperationFunction = self.c.Git().Patch.PatchBuilder.AddFileWhole } for _, node := range selectedNodes { err := node.ForEachFile(func(file *models.CommitFile) error { return patchOperationFunction(file.Path) }) if err != nil { return err } } if self.c.Git().Patch.PatchBuilder.IsEmpty() { self.c.Git().Patch.PatchBuilder.Reset() } self.c.PostRefreshUpdate(self.context()) return nil }) } from, to, reverse := self.currentFromToReverseForPatchBuilding() if self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.DiscardPatch, Prompt: self.c.Tr.DiscardPatchConfirm, HandleConfirm: func() error { self.c.Git().Patch.PatchBuilder.Reset() return toggle() }, }) return nil } return toggle() } func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) error { root := self.context().CommitFileTreeViewModel.GetRoot() return self.toggleForPatch([]*filetree.CommitFileNode{root}) } func (self *CommitFilesController) startPatchBuilder() error { commitFilesContext := self.context() canRebase := commitFilesContext.GetCanRebase() from, to, reverse := self.currentFromToReverseForPatchBuilding() self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase) return nil } func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (string, string, bool) { commitFilesContext := self.context() from, to := commitFilesContext.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) return from, to, reverse } func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error { return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) } func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error { if node.File == nil { return self.handleToggleCommitFileDirCollapsed(node) } if self.c.AppState.DiffContextSize == 0 { return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage, keybindings.Label(self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)) } enterTheFile := func() error { if !self.c.Git().Patch.PatchBuilder.Active() { if err := self.startPatchBuilder(); err != nil { return err } } self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts) return nil } from, to, reverse := self.currentFromToReverseForPatchBuilding() if self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.DiscardPatch, Prompt: self.c.Tr.DiscardPatchConfirm, HandleConfirm: func() error { self.c.Git().Patch.PatchBuilder.Reset() return enterTheFile() }, }) return nil } return enterTheFile() } func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error { self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath()) self.c.PostRefreshUpdate(self.context()) return nil } // NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics func (self *CommitFilesController) toggleTreeView() error { self.context().CommitFileTreeViewModel.ToggleShowTree() self.c.PostRefreshUpdate(self.context()) return nil } func (self *CommitFilesController) collapseAll() error { self.context().CommitFileTreeViewModel.CollapseAll() self.c.PostRefreshUpdate(self.context()) return nil } func (self *CommitFilesController) expandAll() error { self.context().CommitFileTreeViewModel.ExpandAll() self.c.PostRefreshUpdate(self.context()) return nil } func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { node := self.getSelectedItem() if node != nil && node.File != nil { return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx}) } return nil } } // NOTE: these functions are identical to those in files_controller.go (except for types) and // could also be cleaned up with some generics func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) []*filetree.CommitFileNode { return lo.Filter(selectedNodes, func(node *filetree.CommitFileNode, _ int) bool { return !isDescendentOfSelectedCommitFileNodes(node, selectedNodes) }) } func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, selectedNodes []*filetree.CommitFileNode) bool { for _, selectedNode := range selectedNodes { selectedNodePath := selectedNode.GetPath() nodePath := node.GetPath() if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { return true } } return false } func (self *CommitFilesController) isInTreeMode() *types.DisabledReason { if !self.context().CommitFileTreeViewModel.InTreeMode() { return &types.DisabledReason{Text: self.c.Tr.DisabledInFlatView} } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/common.go000066400000000000000000000006271500612110400214670ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" ) type ControllerCommon struct { *helpers.HelperCommon IGetHelpers } type IGetHelpers interface { Helpers() *helpers.Helpers } func NewControllerCommon( c *helpers.HelperCommon, IGetHelpers IGetHelpers, ) *ControllerCommon { return &ControllerCommon{ HelperCommon: c, IGetHelpers: IGetHelpers, } } lazygit-0.50.0+ds1/pkg/gui/controllers/confirmation_controller.go000066400000000000000000000041211500612110400251230ustar00rootroot00000000000000package controllers import ( "fmt" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ConfirmationController struct { baseController c *ControllerCommon } var _ types.IController = &ConfirmationController{} func NewConfirmationController( c *ControllerCommon, ) *ConfirmationController { return &ConfirmationController{ baseController: baseController{}, c: c, } } func (self *ConfirmationController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Confirm), Handler: func() error { return self.context().State.OnConfirm() }, Description: self.c.Tr.Confirm, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: func() error { return self.context().State.OnClose() }, Description: self.c.Tr.CloseCancel, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.TogglePanel), Handler: func() error { if len(self.c.Contexts().Suggestions.State.Suggestions) > 0 { subtitle := "" if self.c.State().GetRepoState().GetCurrentPopupOpts().HandleDeleteSuggestion != nil { // We assume that whenever things are deletable, they // are also editable, so we show both keybindings subtitle = fmt.Sprintf(self.c.Tr.SuggestionsSubtitle, self.c.UserConfig().Keybinding.Universal.Remove, self.c.UserConfig().Keybinding.Universal.Edit) } self.c.Views().Suggestions.Subtitle = subtitle self.c.Context().Replace(self.c.Contexts().Suggestions) } return nil }, }, } return bindings } func (self *ConfirmationController) GetOnFocusLost() func(types.OnFocusLostOpts) { return func(types.OnFocusLostOpts) { self.c.Helpers().Confirmation.DeactivateConfirmationPrompt() } } func (self *ConfirmationController) Context() types.Context { return self.context() } func (self *ConfirmationController) context() *context.ConfirmationContext { return self.c.Contexts().Confirmation } lazygit-0.50.0+ds1/pkg/gui/controllers/context_lines_controller.go000066400000000000000000000074651500612110400253270ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "math" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) // This controller lets you change the context size for diffs. The 'context' in 'context size' refers to the conventional meaning of the word 'context' in a diff, as opposed to lazygit's own idea of a 'context'. var CONTEXT_KEYS_SHOWING_DIFFS = []types.ContextKey{ context.FILES_CONTEXT_KEY, context.COMMIT_FILES_CONTEXT_KEY, context.STASH_CONTEXT_KEY, context.LOCAL_COMMITS_CONTEXT_KEY, context.SUB_COMMITS_CONTEXT_KEY, context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY, context.PATCH_BUILDING_MAIN_CONTEXT_KEY, context.PATCH_BUILDING_SECONDARY_CONTEXT_KEY, context.NORMAL_MAIN_CONTEXT_KEY, context.NORMAL_SECONDARY_CONTEXT_KEY, } type ContextLinesController struct { baseController c *ControllerCommon } var _ types.IController = &ContextLinesController{} func NewContextLinesController( c *ControllerCommon, ) *ContextLinesController { return &ContextLinesController{ baseController: baseController{}, c: c, } } func (self *ContextLinesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.IncreaseContextInDiffView), Handler: self.Increase, Description: self.c.Tr.IncreaseContextInDiffView, Tooltip: self.c.Tr.IncreaseContextInDiffViewTooltip, }, { Key: opts.GetKey(opts.Config.Universal.DecreaseContextInDiffView), Handler: self.Decrease, Description: self.c.Tr.DecreaseContextInDiffView, Tooltip: self.c.Tr.DecreaseContextInDiffViewTooltip, }, } return bindings } func (self *ContextLinesController) Context() types.Context { return nil } func (self *ContextLinesController) Increase() error { if self.isShowingDiff() { if err := self.checkCanChangeContext(); err != nil { return err } if self.c.AppState.DiffContextSize < math.MaxUint64 { self.c.AppState.DiffContextSize++ } return self.applyChange() } return nil } func (self *ContextLinesController) Decrease() error { if self.isShowingDiff() { if err := self.checkCanChangeContext(); err != nil { return err } if self.c.AppState.DiffContextSize > 0 { self.c.AppState.DiffContextSize-- } return self.applyChange() } return nil } func (self *ContextLinesController) applyChange() error { self.c.Toast(fmt.Sprintf(self.c.Tr.DiffContextSizeChanged, self.c.AppState.DiffContextSize)) self.c.SaveAppStateAndLogError() currentContext := self.currentSidePanel() switch currentContext.GetKey() { // we make an exception for our staging and patch building contexts because they actually need to refresh their state afterwards. case context.PATCH_BUILDING_MAIN_CONTEXT_KEY: return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.PATCH_BUILDING}}) case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY: return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}}) default: currentContext.HandleRenderToMain() return nil } } func (self *ContextLinesController) checkCanChangeContext() error { if self.c.Git().Patch.PatchBuilder.Active() { return errors.New(self.c.Tr.CantChangeContextSizeError) } return nil } func (self *ContextLinesController) isShowingDiff() bool { return lo.Contains( CONTEXT_KEYS_SHOWING_DIFFS, self.currentSidePanel().GetKey(), ) } func (self *ContextLinesController) currentSidePanel() types.Context { currentContext := self.c.Context().CurrentStatic() if currentContext.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY || currentContext.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY { return currentContext.GetParentContext() } return currentContext } lazygit-0.50.0+ds1/pkg/gui/controllers/custom_patch_options_menu_action.go000066400000000000000000000172341500612110400270260ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type CustomPatchOptionsMenuAction struct { c *ControllerCommon } func (self *CustomPatchOptionsMenuAction) Call() error { if !self.c.Git().Patch.PatchBuilder.Active() { return errors.New(self.c.Tr.NoPatchError) } if self.c.Git().Patch.PatchBuilder.IsEmpty() { return errors.New(self.c.Tr.EmptyPatchError) } menuItems := []*types.MenuItem{ { Label: self.c.Tr.ResetPatch, Tooltip: self.c.Tr.ResetPatchTooltip, OnPress: self.c.Helpers().PatchBuilding.Reset, Key: 'c', }, { Label: self.c.Tr.ApplyPatch, Tooltip: self.c.Tr.ApplyPatchTooltip, OnPress: func() error { return self.handleApplyPatch(false) }, Key: 'a', }, { Label: self.c.Tr.ApplyPatchInReverse, Tooltip: self.c.Tr.ApplyPatchInReverseTooltip, OnPress: func() error { return self.handleApplyPatch(true) }, Key: 'r', }, } if self.c.Git().Patch.PatchBuilder.CanRebase && self.c.Git().Status.WorkingTreeState().None() { menuItems = append(menuItems, []*types.MenuItem{ { Label: fmt.Sprintf(self.c.Tr.RemovePatchFromOriginalCommit, self.c.Git().Patch.PatchBuilder.To), Tooltip: self.c.Tr.RemovePatchFromOriginalCommitTooltip, OnPress: self.handleDeletePatchFromCommit, Key: 'd', }, { Label: self.c.Tr.MovePatchOutIntoIndex, Tooltip: self.c.Tr.MovePatchOutIntoIndexTooltip, OnPress: self.handleMovePatchIntoWorkingTree, Key: 'i', }, { Label: self.c.Tr.MovePatchIntoNewCommit, Tooltip: self.c.Tr.MovePatchIntoNewCommitTooltip, OnPress: self.handlePullPatchIntoNewCommit, Key: 'n', }, }...) if self.c.Context().Current().GetKey() == self.c.Contexts().LocalCommits.GetKey() { selectedCommit := self.c.Contexts().LocalCommits.GetSelected() if selectedCommit != nil && self.c.Git().Patch.PatchBuilder.To != selectedCommit.Hash() { var disabledReason *types.DisabledReason if self.c.Contexts().LocalCommits.AreMultipleItemsSelected() { disabledReason = &types.DisabledReason{Text: self.c.Tr.RangeSelectNotSupported} } // adding this option to index 1 menuItems = append( menuItems[:1], append( []*types.MenuItem{ { Label: fmt.Sprintf(self.c.Tr.MovePatchToSelectedCommit, selectedCommit.Hash()), Tooltip: self.c.Tr.MovePatchToSelectedCommitTooltip, OnPress: self.handleMovePatchToSelectedCommit, Key: 'm', DisabledReason: disabledReason, }, }, menuItems[1:]..., )..., ) } } } menuItems = append(menuItems, []*types.MenuItem{ { Label: self.c.Tr.CopyPatchToClipboard, OnPress: func() error { return self.copyPatchToClipboard() }, Key: 'y', }, }...) return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.PatchOptionsTitle, Items: menuItems}) } func (self *CustomPatchOptionsMenuAction) getPatchCommitIndex() int { for index, commit := range self.c.Model().Commits { if commit.Hash() == self.c.Git().Patch.PatchBuilder.To { return index } } return -1 } func (self *CustomPatchOptionsMenuAction) validateNormalWorkingTreeState() (bool, error) { if self.c.Git().Status.WorkingTreeState().Any() { return false, errors.New(self.c.Tr.CantPatchWhileRebasingError) } return true, nil } func (self *CustomPatchOptionsMenuAction) returnFocusFromPatchExplorerIfNecessary() { if self.c.Context().Current().GetKey() == self.c.Contexts().CustomPatchBuilder.GetKey() { self.c.Helpers().PatchBuilding.Escape() } } func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error { if ok, err := self.validateNormalWorkingTreeState(); !ok { return err } self.returnFocusFromPatchExplorerIfNecessary() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) } func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() error { if ok, err := self.validateNormalWorkingTreeState(); !ok { return err } self.returnFocusFromPatchExplorerIfNecessary() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.MovePatchToSelectedCommit) err := self.c.Git().Patch.MovePatchToSelectedCommit(self.c.Model().Commits, commitIndex, self.c.Contexts().LocalCommits.GetSelectedLineIdx()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) } func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error { if ok, err := self.validateNormalWorkingTreeState(); !ok { return err } self.returnFocusFromPatchExplorerIfNecessary() pull := func(stash bool) error { return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoIndex) err := self.c.Git().Patch.MovePatchIntoIndex(self.c.Model().Commits, commitIndex, stash) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) } if self.c.Helpers().WorkingTree.IsWorkingTreeDirty() { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.MustStashTitle, Prompt: self.c.Tr.MustStashWarning, HandleConfirm: func() error { return pull(true) }, }) return nil } else { return pull(false) } } func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error { if ok, err := self.validateNormalWorkingTreeState(); !ok { return err } self.returnFocusFromPatchExplorerIfNecessary() commitIndex := self.getPatchCommitIndex() self.c.Helpers().Commits.OpenCommitMessagePanel( &helpers.OpenCommitMessagePanelOpts{ // Pass a commit index of one less than the moved-from commit, so that // you can press up arrow once to recall the original commit message: CommitIndex: commitIndex - 1, InitialMessage: "", SummaryTitle: self.c.Tr.CommitSummaryTitle, DescriptionTitle: self.c.Tr.CommitDescriptionTitle, PreserveMessage: false, OnConfirm: func(summary string, description string) error { return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) err := self.c.Git().Patch.PullPatchIntoNewCommit(self.c.Model().Commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) return nil }) }, }, ) return nil } func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error { self.returnFocusFromPatchExplorerIfNecessary() action := self.c.Tr.Actions.ApplyPatch if reverse { action = "Apply patch in reverse" } self.c.LogAction(action) if err := self.c.Git().Patch.ApplyCustomPatch(reverse, true); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) } func (self *CustomPatchOptionsMenuAction) copyPatchToClipboard() error { patch := self.c.Git().Patch.PatchBuilder.RenderAggregatedPatch(true) self.c.LogAction(self.c.Tr.Actions.CopyPatchToClipboard) if err := self.c.OS().CopyToClipboard(patch); err != nil { return err } self.c.Toast(self.c.Tr.PatchCopiedToClipboard) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/diffing_menu_action.go000066400000000000000000000034731500612110400241700ustar00rootroot00000000000000package controllers import ( "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type DiffingMenuAction struct { c *ControllerCommon } func (self *DiffingMenuAction) Call() error { names := self.c.Helpers().Diff.CurrentDiffTerminals() menuItems := []*types.MenuItem{} for _, name := range names { menuItems = append(menuItems, []*types.MenuItem{ { Label: fmt.Sprintf("%s %s", self.c.Tr.Diff, name), OnPress: func() error { self.c.Modes().Diffing.Ref = name // can scope this down based on current view but too lazy right now return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }, }, }...) } menuItems = append(menuItems, []*types.MenuItem{ { Label: self.c.Tr.EnterRefToDiff, OnPress: func() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.EnterRefName, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRefsSuggestionsFunc(), HandleConfirm: func(response string) error { self.c.Modes().Diffing.Ref = strings.TrimSpace(response) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }, }) return nil }, }, }...) if self.c.Modes().Diffing.Active() { menuItems = append(menuItems, []*types.MenuItem{ { Label: self.c.Tr.SwapDiff, OnPress: func() error { self.c.Modes().Diffing.Reverse = !self.c.Modes().Diffing.Reverse return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }, }, { Label: self.c.Tr.ExitDiffMode, OnPress: func() error { self.c.Modes().Diffing = diffing.New() return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }, }, }...) } return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.DiffingMenuTitle, Items: menuItems}) } lazygit-0.50.0+ds1/pkg/gui/controllers/files_controller.go000066400000000000000000001235321500612110400235450ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "path/filepath" "strings" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type FilesController struct { baseController // nolint: unused *ListControllerTrait[*filetree.FileNode] c *ControllerCommon } var _ types.IController = &FilesController{} func NewFilesController( c *ControllerCommon, ) *FilesController { return &FilesController{ c: c, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Files, c.Contexts().Files.GetSelected, c.Contexts().Files.GetSelectedItems, ), } } func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItems(self.press), GetDisabledReason: self.require(self.itemsSelected()), Description: self.c.Tr.Stage, Tooltip: self.c.Tr.StageTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.OpenStatusFilter), Handler: self.handleStatusFilterPressed, Description: self.c.Tr.FileFilter, }, { Key: opts.GetKey(opts.Config.Files.CopyFileInfoToClipboard), Handler: self.openCopyMenu, Description: self.c.Tr.CopyToClipboardMenu, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Files.CommitChanges), Handler: self.c.Helpers().WorkingTree.HandleCommitPress, Description: self.c.Tr.Commit, Tooltip: self.c.Tr.CommitTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook), Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress, Description: self.c.Tr.CommitChangesWithoutHook, }, { Key: opts.GetKey(opts.Config.Files.AmendLastCommit), Handler: self.handleAmendCommitPress, Description: self.c.Tr.AmendLastCommit, }, { Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor), Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress, Description: self.c.Tr.CommitChangesWithEditor, }, { Key: opts.GetKey(opts.Config.Files.FindBaseCommitForFixup), Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress, Description: self.c.Tr.FindBaseCommitForFixup, Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.withItems(self.edit), GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)), Description: self.c.Tr.Edit, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenFile), Handler: self.Open, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { Key: opts.GetKey(opts.Config.Files.IgnoreFile), Handler: self.withItem(self.ignoreOrExcludeMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Actions.IgnoreExcludeFile, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Files.RefreshFiles), Handler: self.refresh, Description: self.c.Tr.RefreshFiles, }, { Key: opts.GetKey(opts.Config.Files.StashAllChanges), Handler: self.stash, Description: self.c.Tr.Stash, Tooltip: self.c.Tr.StashTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.ViewStashOptions), Handler: self.createStashMenu, Description: self.c.Tr.ViewStashOptions, Tooltip: self.c.Tr.ViewStashOptionsTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Files.ToggleStagedAll), Handler: self.toggleStagedAll, Description: self.c.Tr.ToggleStagedAll, Tooltip: self.c.Tr.ToggleStagedAllTooltip, }, { Key: opts.GetKey(opts.Config.Universal.GoInto), Handler: self.enter, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.FileEnter, Tooltip: self.c.Tr.FileEnterTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItems(self.remove), GetDisabledReason: self.require(self.itemsSelected(self.canRemove)), Description: self.c.Tr.Discard, Tooltip: self.c.Tr.DiscardFileChangesTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), Handler: self.createResetToUpstreamMenu, Description: self.c.Tr.ViewResetToUpstreamOptions, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Files.ViewResetOptions), Handler: self.createResetMenu, Description: self.c.Tr.Reset, Tooltip: self.c.Tr.FileResetOptionsTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.ToggleTreeView), Handler: self.toggleTreeView, Description: self.c.Tr.ToggleTreeView, Tooltip: self.c.Tr.ToggleTreeViewTooltip, }, { Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(self.openDiffTool), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, { Key: opts.GetKey(opts.Config.Files.OpenMergeTool), Handler: self.c.Helpers().WorkingTree.OpenMergeTool, Description: self.c.Tr.OpenMergeTool, Tooltip: self.c.Tr.OpenMergeToolTooltip, }, { Key: opts.GetKey(opts.Config.Files.Fetch), Handler: self.fetch, Description: self.c.Tr.Fetch, Tooltip: self.c.Tr.FetchTooltip, }, { Key: opts.GetKey(opts.Config.Files.CollapseAll), Handler: self.collapseAll, Description: self.c.Tr.CollapseAll, Tooltip: self.c.Tr.CollapseAllTooltip, GetDisabledReason: self.require(self.isInTreeMode), }, { Key: opts.GetKey(opts.Config.Files.ExpandAll), Handler: self.expandAll, Description: self.c.Tr.ExpandAll, Tooltip: self.c.Tr.ExpandAllTooltip, GetDisabledReason: self.require(self.isInTreeMode), }, } } func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: "mergeConflicts", Key: gocui.MouseLeft, Handler: self.onClickMain, FocusedView: self.context().GetViewName(), }, } } func (self *FilesController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { node := self.context().GetSelected() if node == nil { self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: self.c.Tr.DiffTitle, SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: types.NewRenderStringTask(self.c.Tr.NoChangedFiles), }, }) return } if node.File != nil && node.File.HasInlineMergeConflicts { hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) if err != nil { return } if hasConflicts { self.c.Helpers().MergeConflicts.Render() return } } else if node.File != nil && node.File.HasMergeConflicts { opts := types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: self.c.Tr.DiffTitle, SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), }, } message := node.File.GetMergeStateDescription(self.c.Tr) message += "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve, self.c.UserConfig().Keybinding.Universal.GoInto) if self.c.Views().Main.InnerWidth() > 70 { // If the main view is very wide, wrap the message to increase readability lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4) message = strings.Join(lines, "\n") } if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" { cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}) task := types.NewRunPtyTask(cmdObj.GetCmd()) task.Prefix = message + "\n\n" if node.File.ShortStatus == "DU" { task.Prefix += self.c.Tr.MergeConflictIncomingDiff } else { task.Prefix += self.c.Tr.MergeConflictCurrentDiff } task.Prefix += "\n\n" opts.Main.Task = task } else { opts.Main.Task = types.NewRenderStringTask(message) } self.c.RenderToMainViews(opts) return } self.c.Helpers().MergeConflicts.ResetMergeState() split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) mainShowsStaged := !split && node.GetHasStagedChanges() cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged) title := self.c.Tr.UnstagedChanges if mainShowsStaged { title = self.c.Tr.StagedChanges } refreshOpts := types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Task: types.NewRunPtyTask(cmdObj.GetCmd()), SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Title: title, }, } if split { cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true) title := self.c.Tr.StagedChanges if mainShowsStaged { title = self.c.Tr.UnstagedChanges } refreshOpts.Secondary = &types.ViewUpdateOpts{ Title: title, SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: types.NewRunPtyTask(cmdObj.GetCmd()), } } self.c.RenderToMainViews(refreshOpts) }) } } func (self *FilesController) GetOnClick() func() error { return self.withItemGraceful(func(node *filetree.FileNode) error { return self.press([]*filetree.FileNode{node}) }) } func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { node := self.getSelectedItem() if node != nil && node.File != nil { return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx}) } return nil } } // if we are dealing with a status for which there is no key in this map, // then we won't optimistically render: we'll just let `git status` tell // us what the new status is. // There are no doubt more entries that could be added to these two maps. var stageStatusMap = map[string]string{ "??": "A ", " M": "M ", "MM": "M ", " D": "D ", " A": "A ", "AM": "A ", "MD": "D ", } var unstageStatusMap = map[string]string{ "A ": "??", "M ": " M", "D ": " D", } func (self *FilesController) optimisticStage(file *models.File) bool { newShortStatus, ok := stageStatusMap[file.ShortStatus] if !ok { return false } models.SetStatusFields(file, newShortStatus) return true } func (self *FilesController) optimisticUnstage(file *models.File) bool { newShortStatus, ok := unstageStatusMap[file.ShortStatus] if !ok { return false } models.SetStatusFields(file, newShortStatus) return true } // Running a git add command followed by a git status command can take some time (e.g. 200ms). // Given how often users stage/unstage files in Lazygit, we're adding some // optimistic rendering to make things feel faster. When we go to stage // a file, we'll first update that file's status in-memory, then re-render // the files panel. Then we'll immediately do a proper git status call // so that if the optimistic rendering got something wrong, it's quickly // corrected. func (self *FilesController) optimisticChange(nodes []*filetree.FileNode, optimisticChangeFn func(*models.File) bool) error { rerender := false for _, node := range nodes { err := node.ForEachFile(func(f *models.File) error { // can't act on the file itself: we need to update the original model file for _, modelFile := range self.c.Model().Files { if modelFile.Path == f.Path { if optimisticChangeFn(modelFile) { rerender = true } break } } return nil }) if err != nil { return err } } if rerender { self.c.PostRefreshUpdate(self.c.Contexts().Files) } return nil } func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error { // Obtaining this lock because optimistic rendering requires us to mutate // the files in our model. self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() for _, node := range selectedNodes { // if any files within have inline merge conflicts we can't stage or unstage, // or it'll end up with those >>>>>> lines actually staged if node.GetHasInlineMergeConflicts() { return errors.New(self.c.Tr.ErrStageDirWithInlineMergeConflicts) } } toPaths := func(nodes []*filetree.FileNode) []string { return lo.Map(nodes, func(node *filetree.FileNode, _ int) string { return node.GetPath() }) } selectedNodes = normalisedSelectedNodes(selectedNodes) // If any node has unstaged changes, we'll stage all the selected unstaged nodes (staging already staged deleted files/folders would fail). // Otherwise, we unstage all the selected nodes. unstagedSelectedNodes := filterNodesHaveUnstagedChanges(selectedNodes) if len(unstagedSelectedNodes) > 0 { var extraArgs []string if self.context().GetFilter() == filetree.DisplayTracked { extraArgs = []string{"-u"} } self.c.LogAction(self.c.Tr.Actions.StageFile) if err := self.optimisticChange(unstagedSelectedNodes, self.optimisticStage); err != nil { return err } if err := self.c.Git().WorkingTree.StageFiles(toPaths(unstagedSelectedNodes), extraArgs); err != nil { return err } } else { self.c.LogAction(self.c.Tr.Actions.UnstageFile) if err := self.optimisticChange(selectedNodes, self.optimisticUnstage); err != nil { return err } // need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately. trackedNodes, untrackedNodes := utils.Partition(selectedNodes, func(node *filetree.FileNode) bool { // We treat all directories as tracked. I'm not actually sure why we do this but // it's been the existing behaviour for a while and nobody has complained return !node.IsFile() || node.GetIsTracked() }) if len(untrackedNodes) > 0 { if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil { return err } } if len(trackedNodes) > 0 { if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil { return err } } } return nil } func (self *FilesController) press(nodes []*filetree.FileNode) error { if err := self.pressWithLock(nodes); err != nil { return err } if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil { return err } self.context().HandleFocus(types.OnFocusOpts{}) return nil } func (self *FilesController) Context() types.Context { return self.context() } func (self *FilesController) context() *context.WorkingTreeContext { return self.c.Contexts().Files } func (self *FilesController) getSelectedFile() *models.File { node := self.context().GetSelected() if node == nil { return nil } return node.File } func (self *FilesController) enter() error { return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) } func (self *FilesController) collapseAll() error { self.context().FileTreeViewModel.CollapseAll() self.c.PostRefreshUpdate(self.context()) return nil } func (self *FilesController) expandAll() error { self.context().FileTreeViewModel.ExpandAll() self.c.PostRefreshUpdate(self.context()) return nil } func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { node := self.context().GetSelected() if node == nil { return nil } if node.File == nil { return self.handleToggleDirCollapsed() } file := node.File submoduleConfigs := self.c.Model().Submodules if file.IsSubmodule(submoduleConfigs) { submoduleConfig := file.SubmoduleConfig(submoduleConfigs) return self.c.Helpers().Repos.EnterSubmodule(submoduleConfig) } if file.HasInlineMergeConflicts { return self.switchToMerge() } if file.HasMergeConflicts { return self.handleNonInlineConflict(file) } context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging) self.c.Context().Push(context, opts) return nil } func (self *FilesController) handleNonInlineConflict(file *models.File) error { handle := func(command func(command string) error, logText string) error { self.c.LogAction(logText) if err := command(file.GetPath()); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } keepItem := &types.MenuItem{ Label: self.c.Tr.MergeConflictKeepFile, OnPress: func() error { return handle(self.c.Git().WorkingTree.StageFile, self.c.Tr.Actions.ResolveConflictByKeepingFile) }, Key: 'k', } deleteItem := &types.MenuItem{ Label: self.c.Tr.MergeConflictDeleteFile, OnPress: func() error { return handle(self.c.Git().WorkingTree.RemoveConflictedFile, self.c.Tr.Actions.ResolveConflictByDeletingFile) }, Key: 'd', } items := []*types.MenuItem{} switch file.ShortStatus { case "DD": // For "both deleted" conflicts, deleting the file is the only reasonable thing you can do. // Restoring to the state before deletion is not the responsibility of a conflict resolution tool. items = append(items, deleteItem) case "DU", "UD": // For these, we put the delete option first because it's the most common one, // even if it's more destructive. items = append(items, deleteItem, keepItem) case "AU", "UA": // For these, we put the keep option first because it's less destructive, // and the chances between keep and delete are 50/50. items = append(items, keepItem, deleteItem) default: panic("should only be called if there's a merge conflict") } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.MergeConflictsTitle, Prompt: file.GetMergeStateDescription(self.c.Tr), Items: items, }) } func (self *FilesController) toggleStagedAll() error { if err := self.toggleStagedAllWithLock(); err != nil { return err } if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil { return err } self.context().HandleFocus(types.OnFocusOpts{}) return nil } func (self *FilesController) toggleStagedAllWithLock() error { self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() root := self.context().FileTreeViewModel.GetRoot() // if any files within have inline merge conflicts we can't stage or unstage, // or it'll end up with those >>>>>> lines actually staged if root.GetHasInlineMergeConflicts() { return errors.New(self.c.Tr.ErrStageDirWithInlineMergeConflicts) } if root.GetHasUnstagedChanges() { self.c.LogAction(self.c.Tr.Actions.StageAllFiles) if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticStage); err != nil { return err } if err := self.c.Git().WorkingTree.StageAll(); err != nil { return err } } else { self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles) if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticUnstage); err != nil { return err } if err := self.c.Git().WorkingTree.UnstageAll(); err != nil { return err } } return nil } func (self *FilesController) unstageFiles(node *filetree.FileNode) error { return node.ForEachFile(func(file *models.File) error { if file.HasStagedChanges { if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil { return err } } return nil }) } func (self *FilesController) ignoreOrExcludeTracked(node *filetree.FileNode, trAction string, f func(string) error) error { self.c.LogAction(trAction) // not 100% sure if this is necessary but I'll assume it is if err := self.unstageFiles(node); err != nil { return err } if err := self.c.Git().WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil { return err } if err := f(node.GetPath()); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } func (self *FilesController) ignoreOrExcludeUntracked(node *filetree.FileNode, trAction string, f func(string) error) error { self.c.LogAction(trAction) if err := f(node.GetPath()); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } func (self *FilesController) ignoreOrExcludeFile(node *filetree.FileNode, trText string, trPrompt string, trAction string, f func(string) error) error { if node.GetIsTracked() { self.c.Confirm(types.ConfirmOpts{ Title: trText, Prompt: trPrompt, HandleConfirm: func() error { return self.ignoreOrExcludeTracked(node, trAction, f) }, }) return nil } return self.ignoreOrExcludeUntracked(node, trAction, f) } func (self *FilesController) ignore(node *filetree.FileNode) error { if node.GetPath() == ".gitignore" { return errors.New(self.c.Tr.Actions.IgnoreFileErr) } return self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, self.c.Git().WorkingTree.Ignore) } func (self *FilesController) exclude(node *filetree.FileNode) error { if node.GetPath() == ".gitignore" { return errors.New(self.c.Tr.Actions.ExcludeGitIgnoreErr) } return self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.c.Git().WorkingTree.Exclude) } func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Actions.IgnoreExcludeFile, Items: []*types.MenuItem{ { LabelColumns: []string{self.c.Tr.IgnoreFile}, OnPress: func() error { if err := self.ignore(node); err != nil { return err } return nil }, Key: 'i', }, { LabelColumns: []string{self.c.Tr.ExcludeFile}, OnPress: func() error { if err := self.exclude(node); err != nil { return err } return nil }, Key: 'e', }, }, }) } func (self *FilesController) refresh() error { return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } func (self *FilesController) handleAmendCommitPress() error { doAmend := func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { if len(self.c.Model().Commits) == 0 { return errors.New(self.c.Tr.NoCommitToAmend) } return self.c.Helpers().AmendHelper.AmendHead() }) } if self.isResolvingConflicts() { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.AmendCommitTitle, Prompt: self.c.Tr.AmendCommitWithConflictsMenuPrompt, HideCancel: true, // We want the cancel item first, so we add one manually Items: []*types.MenuItem{ { Label: self.c.Tr.Cancel, OnPress: func() error { return nil }, }, { Label: self.c.Tr.AmendCommitWithConflictsContinue, OnPress: func() error { return self.c.Helpers().MergeAndRebase.ContinueRebase() }, }, { Label: self.c.Tr.AmendCommitWithConflictsAmend, OnPress: func() error { return doAmend() }, }, }, }) } else { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.AmendLastCommitTitle, Prompt: self.c.Tr.SureToAmend, HandleConfirm: func() error { return doAmend() }, }) } return nil } func (self *FilesController) isResolvingConflicts() bool { commits := self.c.Model().Commits for _, c := range commits { if c.Status == models.StatusConflicted { return true } if !c.IsTODO() { break } } return false } func (self *FilesController) handleStatusFilterPressed() error { currentFilter := self.context().GetFilter() return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.FilteringMenuTitle, Items: []*types.MenuItem{ { Label: self.c.Tr.FilterStagedFiles, OnPress: func() error { return self.setStatusFiltering(filetree.DisplayStaged) }, Key: 's', Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayStaged), }, { Label: self.c.Tr.FilterUnstagedFiles, OnPress: func() error { return self.setStatusFiltering(filetree.DisplayUnstaged) }, Key: 'u', Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayUnstaged), }, { Label: self.c.Tr.FilterTrackedFiles, OnPress: func() error { return self.setStatusFiltering(filetree.DisplayTracked) }, Key: 't', Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayTracked), }, { Label: self.c.Tr.FilterUntrackedFiles, OnPress: func() error { return self.setStatusFiltering(filetree.DisplayUntracked) }, Key: 'T', Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayUntracked), }, { Label: self.c.Tr.NoFilter, OnPress: func() error { return self.setStatusFiltering(filetree.DisplayAll) }, Key: 'r', Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayAll), }, }, }) } func (self *FilesController) filteringLabel(filter filetree.FileTreeDisplayFilter) string { switch filter { case filetree.DisplayAll: return "" case filetree.DisplayStaged: return self.c.Tr.FilterLabelStagedFiles case filetree.DisplayUnstaged: return self.c.Tr.FilterLabelUnstagedFiles case filetree.DisplayTracked: return self.c.Tr.FilterLabelTrackedFiles case filetree.DisplayUntracked: return self.c.Tr.FilterLabelUntrackedFiles case filetree.DisplayConflicted: return self.c.Tr.FilterLabelConflictingFiles } panic(fmt.Sprintf("Unexpected files display filter: %d", filter)) } func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error { previousFilter := self.context().GetFilter() self.context().FileTreeViewModel.SetStatusFilter(filter) self.c.Contexts().Files.GetView().Subtitle = self.filteringLabel(filter) // Whenever we switch between untracked and other filters, we need to refresh the files view // because the untracked files filter applies when running `git status`. if previousFilter != filter && (previousFilter == filetree.DisplayUntracked || filter == filetree.DisplayUntracked) { return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}) } else { self.c.PostRefreshUpdate(self.context()) return nil } } func (self *FilesController) edit(nodes []*filetree.FileNode) error { return self.c.Helpers().Files.EditFiles(lo.FilterMap(nodes, func(node *filetree.FileNode, _ int) (string, bool) { return node.GetPath(), node.IsFile() })) } func (self *FilesController) canEditFiles(nodes []*filetree.FileNode) *types.DisabledReason { if lo.NoneBy(nodes, func(node *filetree.FileNode) bool { return node.IsFile() }) { return &types.DisabledReason{ Text: self.c.Tr.ErrCannotEditDirectory, ShowErrorInPanel: true, } } return nil } func (self *FilesController) Open() error { node := self.context().GetSelected() if node == nil { return nil } return self.c.Helpers().Files.OpenFile(node.GetPath()) } func (self *FilesController) openDiffTool(node *filetree.FileNode) error { fromCommit := "" reverse := false if self.c.Modes().Diffing.Active() { fromCommit = self.c.Modes().Diffing.Ref reverse = self.c.Modes().Diffing.Reverse } return self.c.RunSubprocessAndRefresh( self.c.Git().Diff.OpenDiffToolCmdObj( git_commands.DiffToolCmdOptions{ Filepath: node.GetPath(), FromCommit: fromCommit, ToCommit: "", Reverse: reverse, IsDirectory: !node.IsFile(), Staged: !node.GetHasUnstagedChanges(), }), ) } func (self *FilesController) switchToMerge() error { file := self.getSelectedFile() if file == nil { return nil } return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Path) } func (self *FilesController) createStashMenu() error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.StashOptions, Items: []*types.MenuItem{ { Label: self.c.Tr.StashAllChanges, OnPress: func() error { if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() { return errors.New(self.c.Tr.NoFilesToStash) } return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges) }, Key: 'a', }, { Label: self.c.Tr.StashAllChangesKeepIndex, OnPress: func() error { if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() { return errors.New(self.c.Tr.NoFilesToStash) } // if there are no staged files it behaves the same as Stash.Save return self.handleStashSave(self.c.Git().Stash.StashAndKeepIndex, self.c.Tr.Actions.StashAllChangesKeepIndex) }, Key: 'i', }, { Label: self.c.Tr.StashIncludeUntrackedChanges, OnPress: func() error { return self.handleStashSave(self.c.Git().Stash.StashIncludeUntrackedChanges, self.c.Tr.Actions.StashIncludeUntrackedChanges) }, Key: 'U', }, { Label: self.c.Tr.StashStagedChanges, OnPress: func() error { // there must be something in staging otherwise the current implementation mucks the stash up if !self.c.Helpers().WorkingTree.AnyStagedFiles() { return errors.New(self.c.Tr.NoTrackedStagedFilesStash) } return self.handleStashSave(self.c.Git().Stash.SaveStagedChanges, self.c.Tr.Actions.StashStagedChanges) }, Key: 's', }, { Label: self.c.Tr.StashUnstagedChanges, OnPress: func() error { if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() { return errors.New(self.c.Tr.NoFilesToStash) } if self.c.Helpers().WorkingTree.AnyStagedFiles() { return self.handleStashSave(self.c.Git().Stash.StashUnstagedChanges, self.c.Tr.Actions.StashUnstagedChanges) } // ordinary stash return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashUnstagedChanges) }, Key: 'u', }, }, }) } func (self *FilesController) openCopyMenu() error { node := self.context().GetSelected() copyNameItem := &types.MenuItem{ Label: self.c.Tr.CopyFileName, OnPress: func() error { if err := self.c.OS().CopyToClipboard(node.Name()); err != nil { return err } self.c.Toast(self.c.Tr.FileNameCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'n', } copyRelativePathItem := &types.MenuItem{ Label: self.c.Tr.CopyRelativeFilePath, OnPress: func() error { if err := self.c.OS().CopyToClipboard(node.GetPath()); err != nil { return err } self.c.Toast(self.c.Tr.FilePathCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'p', } copyAbsolutePathItem := &types.MenuItem{ Label: self.c.Tr.CopyAbsoluteFilePath, OnPress: func() error { if err := self.c.OS().CopyToClipboard(filepath.Join(self.c.Git().RepoPaths.RepoPath(), node.GetPath())); err != nil { return err } self.c.Toast(self.c.Tr.FilePathCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'P', } copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, Tooltip: self.c.Tr.CopyFileDiffTooltip, OnPress: func() error { path := self.context().GetSelectedPath() hasStaged := self.hasPathStagedChanges(node) diff, err := self.c.Git().Diff.GetDiff(hasStaged, "--", path) if err != nil { return err } if err := self.c.OS().CopyToClipboard(diff); err != nil { return err } self.c.Toast(self.c.Tr.FileDiffCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected( func(file *filetree.FileNode) *types.DisabledReason { if !node.GetHasStagedOrTrackedChanges() { return &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError} } return nil }, ))(), Key: 's', } copyAllDiff := &types.MenuItem{ Label: self.c.Tr.CopyAllFilesDiff, Tooltip: self.c.Tr.CopyFileDiffTooltip, OnPress: func() error { hasStaged := self.c.Helpers().WorkingTree.AnyStagedFiles() diff, err := self.c.Git().Diff.GetDiff(hasStaged, "--") if err != nil { return err } if err := self.c.OS().CopyToClipboard(diff); err != nil { return err } self.c.Toast(self.c.Tr.AllFilesDiffCopiedToast) return nil }, DisabledReason: self.require( func() *types.DisabledReason { if !self.anyStagedOrTrackedFile() { return &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError} } return nil }, )(), Key: 'a', } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.CopyToClipboardMenu, Items: []*types.MenuItem{ copyNameItem, copyRelativePathItem, copyAbsolutePathItem, copyFileDiffItem, copyAllDiff, }, }) } func (self *FilesController) anyStagedOrTrackedFile() bool { if !self.c.Helpers().WorkingTree.AnyStagedFiles() { return self.c.Helpers().WorkingTree.AnyTrackedFiles() } return true } func (self *FilesController) hasPathStagedChanges(node *filetree.FileNode) bool { return node.SomeFile(func(t *models.File) bool { return t.HasStagedChanges }) } func (self *FilesController) stash() error { return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges) } func (self *FilesController) createResetToUpstreamMenu() error { return self.c.Helpers().Refs.CreateGitResetMenu("@{upstream}") } func (self *FilesController) handleToggleDirCollapsed() error { node := self.context().GetSelected() if node == nil { return nil } self.context().FileTreeViewModel.ToggleCollapsed(node.GetInternalPath()) self.c.PostRefreshUpdate(self.c.Contexts().Files) return nil } func (self *FilesController) toggleTreeView() error { self.context().FileTreeViewModel.ToggleShowTree() self.c.PostRefreshUpdate(self.context()) return nil } func (self *FilesController) handleStashSave(stashFunc func(message string) error, action string) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.StashChanges, HandleConfirm: func(stashComment string) error { self.c.LogAction(action) if err := stashFunc(stashComment); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) }, }) return nil } func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error { return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y}) } func (self *FilesController) fetch() error { return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error { self.c.LogAction("Fetch") err := self.c.Git().Sync.Fetch(task) if err != nil && strings.Contains(err.Error(), "exit status 128") { return errors.New(self.c.Tr.PassUnameWrong) } _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.SYNC}) if err == nil { err = self.c.Helpers().BranchesHelper.AutoForwardBranches() } return err }) } // Couldn't think of a better term than 'normalised'. Alas. // The idea is that when you select a range of nodes, you will often have both // a node and its parent node selected. If we are trying to discard changes to the // selected nodes, we'll get an error if we try to discard the child after the parent. // So we just need to filter out any nodes from the selection that are descendants // of other nodes func normalisedSelectedNodes(selectedNodes []*filetree.FileNode) []*filetree.FileNode { return lo.Filter(selectedNodes, func(node *filetree.FileNode, _ int) bool { return !isDescendentOfSelectedNodes(node, selectedNodes) }) } func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filetree.FileNode) bool { for _, selectedNode := range selectedNodes { if selectedNode.IsFile() { continue } selectedNodePath := selectedNode.GetPath() nodePath := node.GetPath() if strings.HasPrefix(nodePath, selectedNodePath+"/") { return true } } return false } func someNodesHaveUnstagedChanges(nodes []*filetree.FileNode) bool { return lo.SomeBy(nodes, (*filetree.FileNode).GetHasUnstagedChanges) } func someNodesHaveStagedChanges(nodes []*filetree.FileNode) bool { return lo.SomeBy(nodes, (*filetree.FileNode).GetHasStagedChanges) } func filterNodesHaveUnstagedChanges(nodes []*filetree.FileNode) []*filetree.FileNode { return lo.Filter(nodes, func(node *filetree.FileNode, _ int) bool { return node.GetHasUnstagedChanges() }) } func findSubmoduleNode(nodes []*filetree.FileNode, submodules []*models.SubmoduleConfig) *models.File { for _, node := range nodes { submoduleNode := node.FindFirstFileBy(func(f *models.File) bool { return f.IsSubmodule(submodules) }) if submoduleNode != nil { return submoduleNode } } return nil } func (self *FilesController) canRemove(selectedNodes []*filetree.FileNode) *types.DisabledReason { // Return disabled if the selection contains multiple changed items and includes a submodule change. submodules := self.c.Model().Submodules hasFiles := false uniqueSelectedSubmodules := set.New[*models.SubmoduleConfig]() for _, node := range selectedNodes { _ = node.ForEachFile(func(f *models.File) error { if submodule := f.SubmoduleConfig(submodules); submodule != nil { uniqueSelectedSubmodules.Add(submodule) } else { hasFiles = true } return nil }) if uniqueSelectedSubmodules.Len() > 0 && (hasFiles || uniqueSelectedSubmodules.Len() > 1) { return &types.DisabledReason{Text: self.c.Tr.MultiSelectNotSupportedForSubmodules} } } return nil } func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { submodules := self.c.Model().Submodules selectedNodes = normalisedSelectedNodes(selectedNodes) // If we have one submodule then we must only have one submodule or `canRemove` would have // returned an error submoduleNode := findSubmoduleNode(selectedNodes, submodules) if submoduleNode != nil { submodule := submoduleNode.SubmoduleConfig(submodules) menuItems := []*types.MenuItem{ { Label: self.c.Tr.SubmoduleStashAndReset, OnPress: func() error { return self.ResetSubmodule(submodule) }, }, } return self.c.Menu(types.CreateMenuOptions{Title: submoduleNode.GetPath(), Items: menuItems}) } discardAllChangesItem := types.MenuItem{ Label: self.c.Tr.DiscardAllChanges, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInFile) if self.context().IsSelectingRange() { defer self.context().CancelRangeSelect() } for _, node := range selectedNodes { if err := self.c.Git().WorkingTree.DiscardAllDirChanges(node); err != nil { return err } } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) }, Key: self.c.KeybindingsOpts().GetKey(self.c.UserConfig().Keybinding.Files.ConfirmDiscard), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.DiscardAllTooltip, map[string]string{ "path": self.formattedPaths(selectedNodes), }, ), } discardUnstagedChangesItem := types.MenuItem{ Label: self.c.Tr.DiscardUnstagedChanges, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile) if self.context().IsSelectingRange() { defer self.context().CancelRangeSelect() } for _, node := range selectedNodes { if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(node); err != nil { return err } } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) }, Key: 'u', Tooltip: utils.ResolvePlaceholderString( self.c.Tr.DiscardUnstagedTooltip, map[string]string{ "path": self.formattedPaths(selectedNodes), }, ), } if !someNodesHaveStagedChanges(selectedNodes) || !someNodesHaveUnstagedChanges(selectedNodes) { discardUnstagedChangesItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.DiscardUnstagedDisabled} } menuItems := []*types.MenuItem{ &discardAllChangesItem, &discardUnstagedChangesItem, } return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.DiscardChangesTitle, Items: menuItems}) } func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error { return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetSubmodule) file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) if file != nil { if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil { return err } } if err := self.c.Git().Submodule.Stash(submodule); err != nil { return err } if err := self.c.Git().Submodule.Reset(submodule); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) }) } func (self *FilesController) formattedPaths(nodes []*filetree.FileNode) string { return utils.FormatPaths(lo.Map(nodes, func(node *filetree.FileNode, _ int) string { return node.GetPath() })) } func (self *FilesController) isInTreeMode() *types.DisabledReason { if !self.context().FileTreeViewModel.InTreeMode() { return &types.DisabledReason{Text: self.c.Tr.DisabledInFlatView} } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/filter_controller.go000066400000000000000000000020641500612110400237240ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/types" ) type FilterControllerFactory struct { c *ControllerCommon } func NewFilterControllerFactory(c *ControllerCommon) *FilterControllerFactory { return &FilterControllerFactory{ c: c, } } func (self *FilterControllerFactory) Create(context types.IFilterableContext) *FilterController { return &FilterController{ baseController: baseController{}, c: self.c, context: context, } } type FilterController struct { baseController c *ControllerCommon context types.IFilterableContext } func (self *FilterController) Context() types.Context { return self.context } func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.StartSearch), Handler: self.OpenFilterPrompt, Description: self.c.Tr.StartFilter, }, } } func (self *FilterController) OpenFilterPrompt() error { return self.c.Helpers().Search.OpenFilterPrompt(self.context) } lazygit-0.50.0+ds1/pkg/gui/controllers/filtering_menu_action.go000066400000000000000000000067201500612110400245430ustar00rootroot00000000000000package controllers import ( "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type FilteringMenuAction struct { c *ControllerCommon } func (self *FilteringMenuAction) Call() error { fileName := "" author := "" switch self.c.Context().CurrentSide() { case self.c.Contexts().Files: node := self.c.Contexts().Files.GetSelected() if node != nil { fileName = node.GetPath() } case self.c.Contexts().CommitFiles: node := self.c.Contexts().CommitFiles.GetSelected() if node != nil { fileName = node.GetPath() } case self.c.Contexts().LocalCommits: commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil { author = fmt.Sprintf("%s <%s>", commit.AuthorName, commit.AuthorEmail) } } menuItems := []*types.MenuItem{} tooltip := "" if self.c.Modes().Filtering.Active() { tooltip = self.c.Tr.WillCancelExistingFilterTooltip } if fileName != "" { menuItems = append(menuItems, &types.MenuItem{ Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, fileName), OnPress: func() error { return self.setFilteringPath(fileName) }, Tooltip: tooltip, }) } if author != "" { menuItems = append(menuItems, &types.MenuItem{ Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, author), OnPress: func() error { return self.setFilteringAuthor(author) }, Tooltip: tooltip, }) } menuItems = append(menuItems, &types.MenuItem{ Label: self.c.Tr.FilterPathOption, OnPress: func() error { self.c.Prompt(types.PromptOpts{ FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(), Title: self.c.Tr.EnterFileName, HandleConfirm: func(response string) error { return self.setFilteringPath(strings.TrimSpace(response)) }, }) return nil }, Tooltip: tooltip, }) menuItems = append(menuItems, &types.MenuItem{ Label: self.c.Tr.FilterAuthorOption, OnPress: func() error { self.c.Prompt(types.PromptOpts{ FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), Title: self.c.Tr.EnterAuthor, HandleConfirm: func(response string) error { return self.setFilteringAuthor(strings.TrimSpace(response)) }, }) return nil }, Tooltip: tooltip, }) if self.c.Modes().Filtering.Active() { menuItems = append(menuItems, &types.MenuItem{ Label: self.c.Tr.ExitFilterMode, OnPress: self.c.Helpers().Mode.ClearFiltering, }) } return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.FilteringMenuTitle, Items: menuItems}) } func (self *FilteringMenuAction) setFilteringPath(path string) error { self.c.Modes().Filtering.Reset() self.c.Modes().Filtering.SetPath(path) return self.setFiltering() } func (self *FilteringMenuAction) setFilteringAuthor(author string) error { self.c.Modes().Filtering.Reset() self.c.Modes().Filtering.SetAuthor(author) return self.setFiltering() } func (self *FilteringMenuAction) setFiltering() error { self.c.Modes().Filtering.SetSelectedCommitHash(self.c.Contexts().LocalCommits.GetSelectedCommitHash()) repoState := self.c.State().GetRepoState() if repoState.GetScreenMode() == types.SCREEN_NORMAL { repoState.SetScreenMode(types.SCREEN_HALF) } self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}, Then: func() error { self.c.Contexts().LocalCommits.SetSelection(0) self.c.Contexts().LocalCommits.FocusLine() return nil }}) } lazygit-0.50.0+ds1/pkg/gui/controllers/git_flow_controller.go000066400000000000000000000054111500612110400242500ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type GitFlowController struct { baseController *ListControllerTrait[*models.Branch] c *ControllerCommon } var _ types.IController = &GitFlowController{} func NewGitFlowController( c *ControllerCommon, ) *GitFlowController { return &GitFlowController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Branches, c.Contexts().Branches.GetSelected, c.Contexts().Branches.GetSelectedItems, ), c: c, } } func (self *GitFlowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Branches.ViewGitFlowOptions), Handler: self.withItem(self.handleCreateGitFlowMenu), Description: self.c.Tr.GitFlowOptions, OpensMenu: true, }, } return bindings } func (self *GitFlowController) handleCreateGitFlowMenu(branch *models.Branch) error { if !self.c.Git().Flow.GitFlowEnabled() { return errors.New("You need to install git-flow and enable it in this repo to use git-flow features") } startHandler := func(branchType string) func() error { return func() error { title := utils.ResolvePlaceholderString(self.c.Tr.NewGitFlowBranchPrompt, map[string]string{"branchType": branchType}) self.c.Prompt(types.PromptOpts{ Title: title, HandleConfirm: func(name string) error { self.c.LogAction(self.c.Tr.Actions.GitFlowStart) return self.c.RunSubprocessAndRefresh( self.c.Git().Flow.StartCmdObj(branchType, name), ) }, }) return nil } } return self.c.Menu(types.CreateMenuOptions{ Title: "git flow", Items: []*types.MenuItem{ { // not localising here because it's one to one with the actual git flow commands Label: fmt.Sprintf("finish branch '%s'", branch.Name), OnPress: func() error { return self.gitFlowFinishBranch(branch.Name) }, DisabledReason: self.require(self.singleItemSelected())(), }, { Label: "start feature", OnPress: startHandler("feature"), Key: 'f', }, { Label: "start hotfix", OnPress: startHandler("hotfix"), Key: 'h', }, { Label: "start bugfix", OnPress: startHandler("bugfix"), Key: 'b', }, { Label: "start release", OnPress: startHandler("release"), Key: 'r', }, }, }) } func (self *GitFlowController) gitFlowFinishBranch(branchName string) error { cmdObj, err := self.c.Git().Flow.FinishCmdObj(branchName) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.GitFlowFinish) return self.c.RunSubprocessAndRefresh(cmdObj) } lazygit-0.50.0+ds1/pkg/gui/controllers/global_controller.go000066400000000000000000000142171500612110400237020ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type GlobalController struct { baseController c *ControllerCommon } func NewGlobalController( c *ControllerCommon, ) *GlobalController { return &GlobalController{ baseController: baseController{}, c: c, } } func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.ExecuteShellCommand), Handler: self.shellCommand, Description: self.c.Tr.ExecuteShellCommand, Tooltip: self.c.Tr.ExecuteShellCommandTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Universal.CreatePatchOptionsMenu), Handler: self.createCustomPatchOptionsMenu, Description: self.c.Tr.ViewPatchOptions, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Universal.CreateRebaseOptionsMenu), Handler: opts.Guards.NoPopupPanel(self.c.Helpers().MergeAndRebase.CreateRebaseOptionsMenu), Description: self.c.Tr.ViewMergeRebaseOptions, Tooltip: self.c.Tr.ViewMergeRebaseOptionsTooltip, OpensMenu: true, GetDisabledReason: self.canShowRebaseOptions, }, { Key: opts.GetKey(opts.Config.Universal.Refresh), Handler: opts.Guards.NoPopupPanel(self.refresh), Description: self.c.Tr.Refresh, Tooltip: self.c.Tr.RefreshTooltip, }, { Key: opts.GetKey(opts.Config.Universal.NextScreenMode), Handler: opts.Guards.NoPopupPanel(self.nextScreenMode), Description: self.c.Tr.NextScreenMode, }, { Key: opts.GetKey(opts.Config.Universal.PrevScreenMode), Handler: opts.Guards.NoPopupPanel(self.prevScreenMode), Description: self.c.Tr.PrevScreenMode, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.OptionMenu), Handler: self.createOptionsMenu, OpensMenu: true, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.OptionMenuAlt1), Modifier: gocui.ModNone, // we have the description on the alt key and not the main key for legacy reasons // (the original main key was 'x' but we've reassigned that to other purposes) Description: self.c.Tr.OpenKeybindingsMenu, Handler: self.createOptionsMenu, ShortDescription: self.c.Tr.Keybindings, DisplayOnScreen: true, GetDisabledReason: self.optionsMenuDisabledReason, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.FilteringMenu), Handler: opts.Guards.NoPopupPanel(self.createFilteringMenu), Description: self.c.Tr.OpenFilteringMenu, Tooltip: self.c.Tr.OpenFilteringMenuTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Universal.DiffingMenu), Handler: opts.Guards.NoPopupPanel(self.createDiffingMenu), Description: self.c.Tr.ViewDiffingOptions, Tooltip: self.c.Tr.ViewDiffingOptionsTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Universal.DiffingMenuAlt), Handler: opts.Guards.NoPopupPanel(self.createDiffingMenu), Description: self.c.Tr.ViewDiffingOptions, Tooltip: self.c.Tr.ViewDiffingOptionsTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Universal.Quit), Modifier: gocui.ModNone, Description: self.c.Tr.Quit, Handler: self.quit, }, { Key: opts.GetKey(opts.Config.Universal.QuitAlt1), Modifier: gocui.ModNone, Handler: self.quit, }, { Key: opts.GetKey(opts.Config.Universal.QuitWithoutChangingDirectory), Modifier: gocui.ModNone, Handler: self.quitWithoutChangingDirectory, }, { Key: opts.GetKey(opts.Config.Universal.Return), Modifier: gocui.ModNone, Handler: self.escape, Description: self.c.Tr.Cancel, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.ToggleWhitespaceInDiffView), Handler: self.toggleWhitespace, Description: self.c.Tr.ToggleWhitespaceInDiffView, Tooltip: self.c.Tr.ToggleWhitespaceInDiffViewTooltip, }, } } func (self *GlobalController) Context() types.Context { return nil } func (self *GlobalController) shellCommand() error { return (&ShellCommandAction{c: self.c}).Call() } func (self *GlobalController) createCustomPatchOptionsMenu() error { return (&CustomPatchOptionsMenuAction{c: self.c}).Call() } func (self *GlobalController) refresh() error { return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) } func (self *GlobalController) nextScreenMode() error { return (&ScreenModeActions{c: self.c}).Next() } func (self *GlobalController) prevScreenMode() error { return (&ScreenModeActions{c: self.c}).Prev() } func (self *GlobalController) createOptionsMenu() error { return (&OptionsMenuAction{c: self.c}).Call() } func (self *GlobalController) optionsMenuDisabledReason() *types.DisabledReason { ctx := self.c.Context().Current() // Don't show options menu while displaying popup. if ctx.GetKind() == types.PERSISTENT_POPUP || ctx.GetKind() == types.TEMPORARY_POPUP { // The empty error text is intentional. We don't want to show an error // toast for this, but only hide it from the options map. return &types.DisabledReason{Text: ""} } return nil } func (self *GlobalController) createFilteringMenu() error { return (&FilteringMenuAction{c: self.c}).Call() } func (self *GlobalController) createDiffingMenu() error { return (&DiffingMenuAction{c: self.c}).Call() } func (self *GlobalController) quit() error { return (&QuitActions{c: self.c}).Quit() } func (self *GlobalController) quitWithoutChangingDirectory() error { return (&QuitActions{c: self.c}).QuitWithoutChangingDirectory() } func (self *GlobalController) escape() error { return (&QuitActions{c: self.c}).Escape() } func (self *GlobalController) toggleWhitespace() error { return (&ToggleWhitespaceAction{c: self.c}).Call() } func (self *GlobalController) canShowRebaseOptions() *types.DisabledReason { if self.c.Model().WorkingTreeStateAtLastCommitRefresh.None() { return &types.DisabledReason{ Text: self.c.Tr.NotMergingOrRebasing, } } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/000077500000000000000000000000001500612110400213055ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/amend_helper.go000066400000000000000000000010131500612110400242520ustar00rootroot00000000000000package helpers import "github.com/jesseduffield/lazygit/pkg/commands/git_commands" type AmendHelper struct { c *HelperCommon gpg *GpgHelper } func NewAmendHelper( c *HelperCommon, gpg *GpgHelper, ) *AmendHelper { return &AmendHelper{ c: c, gpg: gpg, } } func (self *AmendHelper) AmendHead() error { cmdObj := self.c.Git().Commit.AmendHeadCmdObj() self.c.LogAction(self.c.Tr.Actions.AmendCommit) return self.gpg.WithGpgHandling(cmdObj, git_commands.CommitGpgSign, self.c.Tr.AmendingStatus, nil, nil) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/app_status_helper.go000066400000000000000000000105041500612110400253560ustar00rootroot00000000000000package helpers import ( "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/status" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type AppStatusHelper struct { c *HelperCommon statusMgr func() *status.StatusManager modeHelper *ModeHelper } func NewAppStatusHelper(c *HelperCommon, statusMgr func() *status.StatusManager, modeHelper *ModeHelper) *AppStatusHelper { return &AppStatusHelper{ c: c, statusMgr: statusMgr, modeHelper: modeHelper, } } func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) { if self.c.RunningIntegrationTest() { // Don't bother showing toasts in integration tests. You can't check for // them anyway, and they would only slow down the test unnecessarily by // two seconds. return } self.statusMgr().AddToastStatus(message, kind) self.renderAppStatus() } // A custom task for WithWaitingStatus calls; it wraps the original one and // hides the status whenever the task is paused, and shows it again when // continued. type appStatusHelperTask struct { gocui.Task waitingStatusHandle *status.WaitingStatusHandle } // poor man's version of explicitly saying that struct X implements interface Y var _ gocui.Task = appStatusHelperTask{} func (self appStatusHelperTask) Pause() { self.waitingStatusHandle.Hide() self.Task.Pause() } func (self appStatusHelperTask) Continue() { self.Task.Continue() self.waitingStatusHandle.Show() } // withWaitingStatus wraps a function and shows a waiting status while the function is still executing func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task) error) { self.c.OnWorker(func(task gocui.Task) error { return self.WithWaitingStatusImpl(message, f, task) }) } func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error { return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error { return f(appStatusHelperTask{task, waitingStatusHandle}) }) } func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error { return self.statusMgr().WithWaitingStatus(message, func() {}, func(*status.WaitingStatusHandle) error { stop := make(chan struct{}) defer func() { close(stop) }() self.renderAppStatusSync(stop) return f() }) } func (self *AppStatusHelper) HasStatus() bool { return self.statusMgr().HasStatus() } func (self *AppStatusHelper) GetStatusString() string { appStatus, _ := self.statusMgr().GetStatusString(self.c.UserConfig()) return appStatus } func (self *AppStatusHelper) renderAppStatus() { self.c.OnWorker(func(_ gocui.Task) error { ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) defer ticker.Stop() for range ticker.C { appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) self.c.Views().AppStatus.FgColor = color self.c.OnUIThread(func() error { self.c.SetViewContent(self.c.Views().AppStatus, appStatus) return nil }) if appStatus == "" { break } } return nil }) } func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) { go func() { ticker := time.NewTicker(time.Millisecond * 50) defer ticker.Stop() // Forcing a re-layout and redraw after we added the waiting status; // this is needed in case the gui.showBottomLine config is set to false, // to make sure the bottom line appears. It's also useful for redrawing // once after each of several consecutive keypresses, e.g. pressing // ctrl-j to move a commit down several steps. _ = self.c.GocuiGui().ForceLayoutAndRedraw() self.modeHelper.SetSuppressRebasingMode(true) defer func() { self.modeHelper.SetSuppressRebasingMode(false) }() outer: for { select { case <-ticker.C: appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) self.c.Views().AppStatus.FgColor = color self.c.SetViewContent(self.c.Views().AppStatus, appStatus) // Redraw all views of the bottom line: bottomLineViews := []*gocui.View{ self.c.Views().AppStatus, self.c.Views().Options, self.c.Views().Information, self.c.Views().StatusSpacer1, self.c.Views().StatusSpacer2, } _ = self.c.GocuiGui().ForceRedrawViews(bottomLineViews...) case <-stop: break outer } } }() } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/bisect_helper.go000066400000000000000000000013621500612110400244460ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/gui/types" ) type BisectHelper struct { c *HelperCommon } func NewBisectHelper(c *HelperCommon) *BisectHelper { return &BisectHelper{c: c} } func (self *BisectHelper) Reset() error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Bisect.ResetTitle, Prompt: self.c.Tr.Bisect.ResetPrompt, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.ResetBisect) if err := self.c.Git().Bisect.Reset(); err != nil { return err } return self.PostBisectCommandRefresh() }, }) return nil } func (self *BisectHelper) PostBisectCommandRefresh() error { return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{}}) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/branches_helper.go000066400000000000000000000217671500612110400247750ustar00rootroot00000000000000package helpers import ( "errors" "fmt" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type BranchesHelper struct { c *HelperCommon worktreeHelper *WorktreeHelper } func NewBranchesHelper(c *HelperCommon, worktreeHelper *WorktreeHelper) *BranchesHelper { return &BranchesHelper{ c: c, worktreeHelper: worktreeHelper, } } func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error { if len(branches) > 1 { if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) { return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) } } else if self.checkedOutByOtherWorktree(branches[0]) { return self.promptWorktreeBranchDelete(branches[0]) } allBranchesMerged, err := self.allBranchesMerged(branches) if err != nil { return err } doDelete := func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(_ gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil { return err } selectionStart, _ := self.c.Contexts().Branches.GetSelectionRange() self.c.Contexts().Branches.SetSelectedLineIdx(selectionStart) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) }) } if allBranchesMerged { return doDelete() } title := self.c.Tr.ForceDeleteBranchTitle var message string if len(branches) == 1 { message = utils.ResolvePlaceholderString( self.c.Tr.ForceDeleteBranchMessage, map[string]string{ "selectedBranchName": branches[0].Name, }, ) } else { message = self.c.Tr.ForceDeleteBranchesMessage } self.c.Confirm(types.ConfirmOpts{ Title: title, Prompt: message, HandleConfirm: func() error { return doDelete() }, }) return nil } func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteBranch) error { var title string if len(remoteBranches) == 1 { title = utils.ResolvePlaceholderString( self.c.Tr.DeleteBranchTitle, map[string]string{ "selectedBranchName": remoteBranches[0].Name, }, ) } else { title = self.c.Tr.DeleteBranchesTitle } var prompt string if len(remoteBranches) == 1 { prompt = utils.ResolvePlaceholderString( self.c.Tr.DeleteRemoteBranchPrompt, map[string]string{ "selectedBranchName": remoteBranches[0].Name, "upstream": remoteBranches[0].RemoteName, }, ) } else { prompt = self.c.Tr.DeleteRemoteBranchesPrompt } self.c.Confirm(types.ConfirmOpts{ Title: title, Prompt: prompt, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) error { if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) }) }, }) return nil } func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branch) error { if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) { return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) } allBranchesMerged, err := self.allBranchesMerged(branches) if err != nil { return err } var prompt string if len(branches) == 1 { prompt = utils.ResolvePlaceholderString( self.c.Tr.DeleteLocalAndRemoteBranchPrompt, map[string]string{ "localBranchName": branches[0].Name, "remoteBranchName": branches[0].UpstreamBranch, "remoteName": branches[0].UpstreamRemote, }, ) } else { prompt = self.c.Tr.DeleteLocalAndRemoteBranchesPrompt } if !allBranchesMerged { if len(branches) == 1 { prompt += "\n\n" + utils.ResolvePlaceholderString( self.c.Tr.ForceDeleteBranchMessage, map[string]string{ "selectedBranchName": branches[0].Name, }, ) } else { prompt += "\n\n" + self.c.Tr.ForceDeleteBranchesMessage } } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.DeleteLocalAndRemoteBranch, Prompt: prompt, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) error { // Delete the remote branches first so that we keep the local ones // in case of failure remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch { return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} }) if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { return err } self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil { return err } selectionStart, _ := self.c.Contexts().Branches.GetSelectionRange() self.c.Contexts().Branches.SetSelectedLineIdx(selectionStart) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) }) }, }) return nil } func ShortBranchName(fullBranchName string) string { return strings.TrimPrefix(strings.TrimPrefix(fullBranchName, "refs/heads/"), "refs/remotes/") } func (self *BranchesHelper) checkedOutByOtherWorktree(branch *models.Branch) bool { return git_commands.CheckedOutByOtherWorktree(branch, self.c.Model().Worktrees) } func (self *BranchesHelper) worktreeForBranch(branch *models.Branch) (*models.Worktree, bool) { return git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees) } func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Branch) error { worktree, ok := self.worktreeForBranch(selectedBranch) if !ok { self.c.Log.Error("promptWorktreeBranchDelete out of sync with list of worktrees") return nil } title := utils.ResolvePlaceholderString(self.c.Tr.BranchCheckedOutByWorktree, map[string]string{ "worktreeName": worktree.Name, "branchName": selectedBranch.Name, }) return self.c.Menu(types.CreateMenuOptions{ Title: title, Items: []*types.MenuItem{ { Label: self.c.Tr.SwitchToWorktree, OnPress: func() error { return self.worktreeHelper.Switch(worktree, context.LOCAL_BRANCHES_CONTEXT_KEY) }, }, { Label: self.c.Tr.DetachWorktree, Tooltip: self.c.Tr.DetachWorktreeTooltip, OnPress: func() error { return self.worktreeHelper.Detach(worktree) }, }, { Label: self.c.Tr.RemoveWorktree, OnPress: func() error { return self.worktreeHelper.Remove(worktree, false) }, }, }, }) } func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) { allBranchesMerged := true for _, branch := range branches { isMerged, err := self.c.Git().Branch.IsBranchMerged(branch, self.c.Model().MainBranches) if err != nil { return false, err } if !isMerged { allBranchesMerged = false break } } return allBranchesMerged, nil } func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.RemoteBranch, task gocui.Task) error { remotes := lo.GroupBy(remoteBranches, func(branch *models.RemoteBranch) string { return branch.RemoteName }) for remote, branches := range remotes { self.c.LogAction(self.c.Tr.Actions.DeleteRemoteBranch) branchNames := lo.Map(branches, func(branch *models.RemoteBranch, _ int) string { return branch.Name }) if err := self.c.Git().Remote.DeleteRemoteBranch(task, remote, branchNames); err != nil { return err } } return nil } func (self *BranchesHelper) AutoForwardBranches() error { if self.c.UserConfig().Git.AutoForwardBranches == "none" { return nil } allBranches := self.c.UserConfig().Git.AutoForwardBranches == "allBranches" branches := self.c.Model().Branches updateCommands := "" // The first branch is the currently checked out branch; skip it for _, branch := range branches[1:] { if branch.RemoteBranchStoredLocally() && (allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) { isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull() if isStrictlyBehind { updateCommands += fmt.Sprintf("update %s %s %s\n", branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash) } } } if updateCommands == "" { return nil } self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches) self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) err := self.c.Git().Branch.UpdateBranchRefs(updateCommands) _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC}) return err } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/cherry_pick_helper.go000066400000000000000000000075171500612110400255070ustar00rootroot00000000000000package helpers import ( "strconv" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type CherryPickHelper struct { c *HelperCommon rebaseHelper *MergeAndRebaseHelper } // I'm using the analogy of copy+paste in the terminology here because it's intuitively what's going on, // even if in truth we're running git cherry-pick func NewCherryPickHelper( c *HelperCommon, rebaseHelper *MergeAndRebaseHelper, ) *CherryPickHelper { return &CherryPickHelper{ c: c, rebaseHelper: rebaseHelper, } } func (self *CherryPickHelper) getData() *cherrypicking.CherryPicking { return self.c.Modes().CherryPicking } func (self *CherryPickHelper) CopyRange(commitsList []*models.Commit, context types.IListContext) error { startIdx, endIdx := context.GetList().GetSelectionRange() if err := self.resetIfNecessary(context); err != nil { return err } commitSet := self.getData().SelectedHashSet() allCommitsCopied := lo.EveryBy(commitsList[startIdx:endIdx+1], func(commit *models.Commit) bool { return commitSet.Includes(commit.Hash()) }) // if all selected commits are already copied, we'll uncopy them if allCommitsCopied { for index := startIdx; index <= endIdx; index++ { commit := commitsList[index] self.getData().Remove(commit, commitsList) } } else { for index := startIdx; index <= endIdx; index++ { commit := commitsList[index] self.getData().Add(commit, commitsList) } } self.getData().DidPaste = false self.rerender() return nil } // HandlePasteCommits begins a cherry-pick rebase with the commits the user has copied. // Only to be called from the branch commits controller func (self *CherryPickHelper) Paste() error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.CherryPick, Prompt: utils.ResolvePlaceholderString( self.c.Tr.SureCherryPick, map[string]string{ "numCommits": strconv.Itoa(len(self.getData().CherryPickedCommits)), }), HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.CherryPickingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.CherryPick) result := self.c.Git().Rebase.CherryPickCommits(self.getData().CherryPickedCommits) err := self.rebaseHelper.CheckMergeOrRebase(result) if err != nil { return result } // If we're in the cherry-picking state at this point, it must // be because there were conflicts. Don't clear the copied // commits in this case, since we might want to abort and try // pasting them again. isInCherryPick, result := self.c.Git().Status.IsInCherryPick() if result != nil { return result } if !isInCherryPick { self.getData().DidPaste = true self.rerender() } return nil }) }, }) return nil } func (self *CherryPickHelper) CanPaste() bool { return self.getData().CanPaste() } func (self *CherryPickHelper) Reset() error { self.getData().ContextKey = "" self.getData().CherryPickedCommits = nil self.rerender() return nil } // you can only copy from one context at a time, because the order and position of commits matter func (self *CherryPickHelper) resetIfNecessary(context types.Context) error { oldContextKey := types.ContextKey(self.getData().ContextKey) if oldContextKey != context.GetKey() { // need to reset the cherry picking mode self.getData().ContextKey = string(context.GetKey()) self.getData().CherryPickedCommits = make([]*models.Commit, 0) } return nil } func (self *CherryPickHelper) rerender() { for _, context := range []types.Context{ self.c.Contexts().LocalCommits, self.c.Contexts().ReflogCommits, self.c.Contexts().SubCommits, } { self.c.PostRefreshUpdate(context) } } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/commits_helper.go000066400000000000000000000177351500612110400246630ustar00rootroot00000000000000package helpers import ( "errors" "path/filepath" "strings" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type CommitsHelper struct { c *HelperCommon getCommitSummary func() string setCommitSummary func(string) getCommitDescription func() string getUnwrappedCommitDescription func() string setCommitDescription func(string) } func NewCommitsHelper( c *HelperCommon, getCommitSummary func() string, setCommitSummary func(string), getCommitDescription func() string, getUnwrappedCommitDescription func() string, setCommitDescription func(string), ) *CommitsHelper { return &CommitsHelper{ c: c, getCommitSummary: getCommitSummary, setCommitSummary: setCommitSummary, getCommitDescription: getCommitDescription, getUnwrappedCommitDescription: getUnwrappedCommitDescription, setCommitDescription: setCommitDescription, } } func (self *CommitsHelper) SplitCommitMessageAndDescription(message string) (string, string) { msg, description, _ := strings.Cut(message, "\n") return msg, strings.TrimSpace(description) } func (self *CommitsHelper) SetMessageAndDescriptionInView(message string) { summary, description := self.SplitCommitMessageAndDescription(message) self.setCommitSummary(summary) self.setCommitDescription(description) self.c.Contexts().CommitMessage.RenderSubtitle() } func (self *CommitsHelper) JoinCommitMessageAndUnwrappedDescription() string { if len(self.getUnwrappedCommitDescription()) == 0 { return self.getCommitSummary() } return self.getCommitSummary() + "\n" + self.getUnwrappedCommitDescription() } func TryRemoveHardLineBreaks(message string, autoWrapWidth int) string { messageRunes := []rune(message) lastHardLineStart := 0 for i, r := range messageRunes { if r == '\n' { // Try to make this a soft linebreak by turning it into a space, and // checking whether it still wraps to the same result then. messageRunes[i] = ' ' _, cursorMapping := gocui.AutoWrapContent(messageRunes[lastHardLineStart:], autoWrapWidth) // Look at the cursorMapping to check whether auto-wrapping inserted // a line break. If it did, there will be a cursorMapping entry with // Orig pointing to the position after the inserted line break. if len(cursorMapping) == 0 || cursorMapping[0].Orig != i-lastHardLineStart+1 { // It didn't, so change it back to a newline messageRunes[i] = '\n' } lastHardLineStart = i + 1 } } return string(messageRunes) } func (self *CommitsHelper) SwitchToEditor() error { message := lo.Ternary(len(self.getCommitDescription()) == 0, self.getCommitSummary(), self.getCommitSummary()+"\n\n"+self.getCommitDescription()) filepath := filepath.Join(self.c.OS().GetTempDir(), self.c.Git().RepoPaths.RepoName(), time.Now().Format("Jan _2 15.04.05.000000000")+".msg") err := self.c.OS().CreateFileWithContent(filepath, message) if err != nil { return err } self.CloseCommitMessagePanel() return self.c.Contexts().CommitMessage.SwitchToEditor(filepath) } func (self *CommitsHelper) UpdateCommitPanelView(message string) { if message != "" { self.SetMessageAndDescriptionInView(message) return } if self.c.Contexts().CommitMessage.GetPreserveMessage() { preservedMessage := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError() self.SetMessageAndDescriptionInView(preservedMessage) return } self.SetMessageAndDescriptionInView("") } type OpenCommitMessagePanelOpts struct { CommitIndex int SummaryTitle string DescriptionTitle string PreserveMessage bool OnConfirm func(summary string, description string) error OnSwitchToEditor func(string) error InitialMessage string // The following two fields are only for the display of the "(hooks // disabled)" display in the commit message panel. They have no effect on // the actual behavior; make sure what you are passing in matches that. // Leave unassigned if the concept of skipping hooks doesn't make sense for // what you are doing, e.g. when creating a tag. ForceSkipHooks bool SkipHooksPrefix string } func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOpts) { onConfirm := func(summary string, description string) error { self.CloseCommitMessagePanel() return opts.OnConfirm(summary, description) } self.c.Contexts().CommitMessage.SetPanelState( opts.CommitIndex, opts.SummaryTitle, opts.DescriptionTitle, opts.PreserveMessage, opts.InitialMessage, onConfirm, opts.OnSwitchToEditor, opts.ForceSkipHooks, opts.SkipHooksPrefix, ) self.UpdateCommitPanelView(opts.InitialMessage) self.c.Context().Push(self.c.Contexts().CommitMessage, types.OnFocusOpts{}) } func (self *CommitsHelper) OnCommitSuccess() { // if we have a preserved message we want to clear it on success if self.c.Contexts().CommitMessage.GetPreserveMessage() { self.c.Contexts().CommitMessage.SetPreservedMessageAndLogError("") } } func (self *CommitsHelper) HandleCommitConfirm() error { summary, description := self.getCommitSummary(), self.getCommitDescription() if summary == "" { return errors.New(self.c.Tr.CommitWithoutMessageErr) } err := self.c.Contexts().CommitMessage.OnConfirm(summary, description) if err != nil { return err } return nil } func (self *CommitsHelper) CloseCommitMessagePanel() { if self.c.Contexts().CommitMessage.GetPreserveMessage() { message := self.JoinCommitMessageAndUnwrappedDescription() if message != self.c.Contexts().CommitMessage.GetInitialMessage() { self.c.Contexts().CommitMessage.SetPreservedMessageAndLogError(message) } } else { self.SetMessageAndDescriptionInView("") } self.c.Contexts().CommitMessage.SetHistoryMessage("") self.c.Views().CommitMessage.Visible = false self.c.Views().CommitDescription.Visible = false self.c.Context().Pop() } func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.Suggestion) error { var disabledReasonForOpenInEditor *types.DisabledReason if !self.c.Contexts().CommitMessage.CanSwitchToEditor() { disabledReasonForOpenInEditor = &types.DisabledReason{ Text: self.c.Tr.CommandDoesNotSupportOpeningInEditor, } } menuItems := []*types.MenuItem{ { Label: self.c.Tr.OpenInEditor, OnPress: func() error { return self.SwitchToEditor() }, Key: 'e', DisabledReason: disabledReasonForOpenInEditor, }, { Label: self.c.Tr.AddCoAuthor, OnPress: func() error { return self.addCoAuthor(suggestionFunc) }, Key: 'c', }, { Label: self.c.Tr.PasteCommitMessageFromClipboard, OnPress: func() error { return self.pasteCommitMessageFromClipboard() }, Key: 'p', }, } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.CommitMenuTitle, Items: menuItems, }) } func (self *CommitsHelper) addCoAuthor(suggestionFunc func(string) []*types.Suggestion) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: suggestionFunc, HandleConfirm: func(value string) error { commitDescription := self.getCommitDescription() commitDescription = git_commands.AddCoAuthorToDescription(commitDescription, value) self.setCommitDescription(commitDescription) return nil }, }) return nil } func (self *CommitsHelper) pasteCommitMessageFromClipboard() error { message, err := self.c.OS().PasteFromClipboard() if err != nil { return err } if message == "" { return nil } if currentMessage := self.JoinCommitMessageAndUnwrappedDescription(); currentMessage == "" { self.SetMessageAndDescriptionInView(message) return nil } // Confirm before overwriting the commit message self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.PasteCommitMessageFromClipboard, Prompt: self.c.Tr.SurePasteCommitMessage, HandleConfirm: func() error { self.SetMessageAndDescriptionInView(message) return nil }, }) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/commits_helper_test.go000066400000000000000000000016031500612110400257050ustar00rootroot00000000000000package helpers import ( "testing" "github.com/stretchr/testify/assert" ) func TestTryRemoveHardLineBreaks(t *testing.T) { scenarios := []struct { name string message string autoWrapWidth int expectedResult string }{ { name: "empty", message: "", autoWrapWidth: 7, expectedResult: "", }, { name: "all line breaks are needed", message: "abc\ndef\n\nxyz", autoWrapWidth: 7, expectedResult: "abc\ndef\n\nxyz", }, { name: "some can be unwrapped", message: "123\nabc def\nghi jkl\nmno\n456\n", autoWrapWidth: 7, expectedResult: "123\nabc def ghi jkl mno\n456\n", }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { actualResult := TryRemoveHardLineBreaks(s.message, s.autoWrapWidth) assert.Equal(t, s.expectedResult, actualResult) }) } } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/confirmation_helper.go000066400000000000000000000307661500612110400256770ustar00rootroot00000000000000package helpers import ( goContext "context" "fmt" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" ) type ConfirmationHelper struct { c *HelperCommon } func NewConfirmationHelper(c *HelperCommon) *ConfirmationHelper { return &ConfirmationHelper{ c: c, } } // This file is for the rendering of confirmation panels along with setting and handling associated // keybindings. func (self *ConfirmationHelper) wrappedConfirmationFunction(cancel goContext.CancelFunc, function func() error) func() error { return func() error { cancel() self.c.Context().Pop() if function != nil { if err := function(); err != nil { return err } } return nil } } func (self *ConfirmationHelper) wrappedPromptConfirmationFunction(cancel goContext.CancelFunc, function func(string) error, getResponse func() string) func() error { return self.wrappedConfirmationFunction(cancel, func() error { return function(getResponse()) }) } func (self *ConfirmationHelper) DeactivateConfirmationPrompt() { self.c.Mutexes().PopupMutex.Lock() self.c.State().GetRepoState().SetCurrentPopupOpts(nil) self.c.Mutexes().PopupMutex.Unlock() self.c.Views().Confirmation.Visible = false self.c.Views().Suggestions.Visible = false self.clearConfirmationViewKeyBindings() } func getMessageHeight(wrap bool, editable bool, message string, width int, tabWidth int) int { wrappedLines, _, _ := utils.WrapViewLinesToWidth(wrap, editable, message, width, tabWidth) return len(wrappedLines) } func (self *ConfirmationHelper) getPopupPanelDimensionsForContentHeight(panelWidth, contentHeight int, parentPopupContext types.Context) (int, int, int, int) { return self.getPopupPanelDimensionsAux(panelWidth, contentHeight, parentPopupContext) } func (self *ConfirmationHelper) getPopupPanelDimensionsAux(panelWidth int, panelHeight int, parentPopupContext types.Context) (int, int, int, int) { width, height := self.c.GocuiGui().Size() if panelHeight > height*3/4 { panelHeight = height * 3 / 4 } if parentPopupContext != nil { // If there's already a popup on the screen, offset the new one from its // parent so that it's clearly distinguished from the parent x0, y0, _, _ := parentPopupContext.GetView().Dimensions() x0 += 2 y0 += 1 return x0, y0, x0 + panelWidth, y0 + panelHeight + 1 } return width/2 - panelWidth/2, height/2 - panelHeight/2 - panelHeight%2 - 1, width/2 + panelWidth/2, height/2 + panelHeight/2 } func (self *ConfirmationHelper) getPopupPanelWidth() int { width, _ := self.c.GocuiGui().Size() // we want a minimum width up to a point, then we do it based on ratio. panelWidth := 4 * width / 7 minWidth := 80 if panelWidth < minWidth { if width-2 < minWidth { panelWidth = width - 2 } else { panelWidth = minWidth } } return panelWidth } func (self *ConfirmationHelper) prepareConfirmationPanel( opts types.ConfirmOpts, ) { self.c.Views().Confirmation.Title = opts.Title // for now we do not support wrapping in our editor self.c.Views().Confirmation.Wrap = !opts.Editable self.c.Views().Confirmation.FgColor = theme.GocuiDefaultTextColor self.c.Views().Confirmation.Mask = runeForMask(opts.Mask) self.c.Views().Confirmation.SetOrigin(0, 0) suggestionsContext := self.c.Contexts().Suggestions suggestionsContext.State.FindSuggestions = opts.FindSuggestionsFunc if opts.FindSuggestionsFunc != nil { suggestionsView := self.c.Views().Suggestions suggestionsView.Wrap = false suggestionsView.FgColor = theme.GocuiDefaultTextColor suggestionsContext.SetSuggestions(opts.FindSuggestionsFunc("")) suggestionsView.Visible = true suggestionsView.Title = fmt.Sprintf(self.c.Tr.SuggestionsTitle, self.c.UserConfig().Keybinding.Universal.TogglePanel) suggestionsView.Subtitle = "" } } func runeForMask(mask bool) rune { if mask { return '*' } return 0 } func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts types.CreatePopupPanelOpts) { self.c.Mutexes().PopupMutex.Lock() defer self.c.Mutexes().PopupMutex.Unlock() _, cancel := goContext.WithCancel(ctx) // we don't allow interruptions of non-loader popups in case we get stuck somehow // e.g. a credentials popup never gets its required user input so a process hangs // forever. // The proper solution is to have a queue of popup options currentPopupOpts := self.c.State().GetRepoState().GetCurrentPopupOpts() if currentPopupOpts != nil && !currentPopupOpts.HasLoader { self.c.Log.Error("ignoring create popup panel because a popup panel is already open") cancel() return } // remove any previous keybindings self.clearConfirmationViewKeyBindings() self.prepareConfirmationPanel( types.ConfirmOpts{ Title: opts.Title, Prompt: opts.Prompt, FindSuggestionsFunc: opts.FindSuggestionsFunc, Editable: opts.Editable, Mask: opts.Mask, }) confirmationView := self.c.Views().Confirmation confirmationView.Editable = opts.Editable if opts.Editable { textArea := confirmationView.TextArea textArea.Clear() textArea.TypeString(opts.Prompt) confirmationView.RenderTextArea() } else { self.c.ResetViewOrigin(confirmationView) self.c.SetViewContent(confirmationView, style.AttrBold.Sprint(opts.Prompt)) } self.setKeyBindings(cancel, opts) self.c.Contexts().Suggestions.State.AllowEditSuggestion = opts.AllowEditSuggestion self.c.State().GetRepoState().SetCurrentPopupOpts(&opts) self.c.Context().Push(self.c.Contexts().Confirmation, types.OnFocusOpts{}) } func (self *ConfirmationHelper) setKeyBindings(cancel goContext.CancelFunc, opts types.CreatePopupPanelOpts) { var onConfirm func() error if opts.HandleConfirmPrompt != nil { onConfirm = self.wrappedPromptConfirmationFunction(cancel, opts.HandleConfirmPrompt, func() string { return self.c.Views().Confirmation.TextArea.GetContent() }) } else { onConfirm = self.wrappedConfirmationFunction(cancel, opts.HandleConfirm) } onSuggestionConfirm := self.wrappedPromptConfirmationFunction( cancel, opts.HandleConfirmPrompt, self.getSelectedSuggestionValue, ) onClose := self.wrappedConfirmationFunction(cancel, opts.HandleClose) onDeleteSuggestion := func() error { if opts.HandleDeleteSuggestion == nil { return nil } idx := self.c.Contexts().Suggestions.GetSelectedLineIdx() return opts.HandleDeleteSuggestion(idx) } self.c.Contexts().Confirmation.State.OnConfirm = onConfirm self.c.Contexts().Confirmation.State.OnClose = onClose self.c.Contexts().Suggestions.State.OnConfirm = onSuggestionConfirm self.c.Contexts().Suggestions.State.OnClose = onClose self.c.Contexts().Suggestions.State.OnDeleteSuggestion = onDeleteSuggestion } func (self *ConfirmationHelper) clearConfirmationViewKeyBindings() { noop := func() error { return nil } self.c.Contexts().Confirmation.State.OnConfirm = noop self.c.Contexts().Confirmation.State.OnClose = noop self.c.Contexts().Suggestions.State.OnConfirm = noop self.c.Contexts().Suggestions.State.OnClose = noop self.c.Contexts().Suggestions.State.OnDeleteSuggestion = noop } func (self *ConfirmationHelper) getSelectedSuggestionValue() string { selectedSuggestion := self.c.Contexts().Suggestions.GetSelected() if selectedSuggestion != nil { return selectedSuggestion.Value } return "" } func (self *ConfirmationHelper) ResizeCurrentPopupPanels() { var parentPopupContext types.Context for _, c := range self.c.Context().CurrentPopup() { switch c { case self.c.Contexts().Menu: self.resizeMenu(parentPopupContext) case self.c.Contexts().Confirmation, self.c.Contexts().Suggestions: self.resizeConfirmationPanel(parentPopupContext) case self.c.Contexts().CommitMessage, self.c.Contexts().CommitDescription: self.ResizeCommitMessagePanels(parentPopupContext) } parentPopupContext = c } } func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) { // we want the unfiltered length here so that if we're filtering we don't // resize the window itemCount := self.c.Contexts().Menu.UnfilteredLen() offset := 3 panelWidth := self.getPopupPanelWidth() contentWidth := panelWidth - 2 // minus 2 for the frame promptLinesCount := self.layoutMenuPrompt(contentWidth) x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(panelWidth, itemCount+offset+promptLinesCount, parentPopupContext) menuBottom := y1 - offset _, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0) tooltipTop := menuBottom + 1 tooltip := "" selectedItem := self.c.Contexts().Menu.GetSelected() if selectedItem != nil { tooltip = self.TooltipForMenuItem(selectedItem) } tooltipHeight := getMessageHeight(true, false, tooltip, contentWidth, self.c.Views().Menu.TabWidth) + 2 // plus 2 for the frame _, _ = self.c.GocuiGui().SetView(self.c.Views().Tooltip.Name(), x0, tooltipTop, x1, tooltipTop+tooltipHeight-1, 0) } // Wraps the lines of the menu prompt to the available width and rerenders the // menu if needed. Returns the number of lines the prompt takes up. func (self *ConfirmationHelper) layoutMenuPrompt(contentWidth int) int { oldPromptLines := self.c.Contexts().Menu.GetPromptLines() var promptLines []string prompt := self.c.Contexts().Menu.GetPrompt() if len(prompt) > 0 { promptLines, _, _ = utils.WrapViewLinesToWidth(true, false, prompt, contentWidth, self.c.Views().Menu.TabWidth) promptLines = append(promptLines, "") } self.c.Contexts().Menu.SetPromptLines(promptLines) if len(oldPromptLines) != len(promptLines) { // The number of lines in the prompt has changed; this happens either // because we're now showing a menu that has a prompt, and the previous // menu didn't (or vice versa), or because the user is resizing the // terminal window while a menu with a prompt is open. // We need to rerender to give the menu context a chance to update its // non-model items, and reinitialize the data it uses for converting // between view index and model index. self.c.Contexts().Menu.HandleRender() // Then we need to refocus to ensure the cursor is in the right place in // the view. self.c.Contexts().Menu.HandleFocus(types.OnFocusOpts{}) } return len(promptLines) } func (self *ConfirmationHelper) resizeConfirmationPanel(parentPopupContext types.Context) { suggestionsViewHeight := 0 if self.c.Views().Suggestions.Visible { suggestionsViewHeight = 11 } panelWidth := self.getPopupPanelWidth() contentWidth := panelWidth - 2 // minus 2 for the frame confirmationView := self.c.Views().Confirmation prompt := confirmationView.Buffer() wrap := true editable := confirmationView.Editable if editable { prompt = confirmationView.TextArea.GetContent() wrap = false } panelHeight := getMessageHeight(wrap, editable, prompt, contentWidth, confirmationView.TabWidth) + suggestionsViewHeight x0, y0, x1, y1 := self.getPopupPanelDimensionsAux(panelWidth, panelHeight, parentPopupContext) confirmationViewBottom := y1 - suggestionsViewHeight _, _ = self.c.GocuiGui().SetView(confirmationView.Name(), x0, y0, x1, confirmationViewBottom, 0) suggestionsViewTop := confirmationViewBottom + 1 _, _ = self.c.GocuiGui().SetView(self.c.Views().Suggestions.Name(), x0, suggestionsViewTop, x1, suggestionsViewTop+suggestionsViewHeight, 0) } func (self *ConfirmationHelper) ResizeCommitMessagePanels(parentPopupContext types.Context) { panelWidth := self.getPopupPanelWidth() content := self.c.Views().CommitDescription.TextArea.GetContent() summaryViewHeight := 3 panelHeight := getMessageHeight(false, true, content, panelWidth, self.c.Views().CommitDescription.TabWidth) minHeight := 7 if panelHeight < minHeight { panelHeight = minHeight } x0, y0, x1, y1 := self.getPopupPanelDimensionsAux(panelWidth, panelHeight, parentPopupContext) _, _ = self.c.GocuiGui().SetView(self.c.Views().CommitMessage.Name(), x0, y0, x1, y0+summaryViewHeight-1, 0) _, _ = self.c.GocuiGui().SetView(self.c.Views().CommitDescription.Name(), x0, y0+summaryViewHeight, x1, y1+summaryViewHeight, 0) } func (self *ConfirmationHelper) IsPopupPanel(context types.Context) bool { return context.GetKind() == types.PERSISTENT_POPUP || context.GetKind() == types.TEMPORARY_POPUP } func (self *ConfirmationHelper) IsPopupPanelFocused() bool { return self.IsPopupPanel(self.c.Context().Current()) } func (self *ConfirmationHelper) TooltipForMenuItem(menuItem *types.MenuItem) string { tooltip := menuItem.Tooltip if menuItem.DisabledReason != nil { if tooltip != "" { tooltip += "\n\n" } tooltip += style.FgRed.Sprintf(self.c.Tr.DisabledMenuItemPrefix) + menuItem.DisabledReason.Text } return tooltip } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/credentials_helper.go000066400000000000000000000033431500612110400254730ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type CredentialsHelper struct { c *HelperCommon } func NewCredentialsHelper( c *HelperCommon, ) *CredentialsHelper { return &CredentialsHelper{ c: c, } } // promptUserForCredential wait for a username, password or passphrase input from the credentials popup // We return a channel rather than returning the string directly so that the calling function knows // when the prompt has been created (before the user has entered anything) so that it can // note that we're now waiting on user input and lazygit isn't processing anything. func (self *CredentialsHelper) PromptUserForCredential(passOrUname oscommands.CredentialType) <-chan string { ch := make(chan string) self.c.OnUIThread(func() error { title, mask := self.getTitleAndMask(passOrUname) self.c.Prompt(types.PromptOpts{ Title: title, Mask: mask, HandleConfirm: func(input string) error { ch <- input + "\n" return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }, HandleClose: func() error { ch <- "\n" return nil }, }) return nil }) return ch } func (self *CredentialsHelper) getTitleAndMask(passOrUname oscommands.CredentialType) (string, bool) { switch passOrUname { case oscommands.Username: return self.c.Tr.CredentialsUsername, false case oscommands.Password: return self.c.Tr.CredentialsPassword, true case oscommands.Passphrase: return self.c.Tr.CredentialsPassphrase, true case oscommands.PIN: return self.c.Tr.CredentialsPIN, true case oscommands.Token: return self.c.Tr.CredentialsToken, true } // should never land here panic("unexpected credential request") } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/diff_helper.go000066400000000000000000000145031500612110400241060ustar00rootroot00000000000000package helpers import ( "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type DiffHelper struct { c *HelperCommon } func NewDiffHelper(c *HelperCommon) *DiffHelper { return &DiffHelper{ c: c, } } func (self *DiffHelper) DiffArgs() []string { output := []string{"--stat", "-p", self.c.Modes().Diffing.Ref} right := self.currentDiffTerminal() if right != "" { output = append(output, right) } if self.c.Modes().Diffing.Reverse { output = append(output, "-R") } output = append(output, "--") file := self.currentlySelectedFilename() if file != "" { output = append(output, file) } else if self.c.Modes().Filtering.Active() { output = append(output, self.c.Modes().Filtering.GetPath()) } return output } // Returns an update task that can be passed to RenderToMainViews to render a // diff for the selected commit(s). We need to pass both the selected commit // and the refRange for a range selection. If the refRange is nil (meaning that // either there's no range, or it can't be diffed for some reason), then we want // to fall back to rendering the diff for the single commit. func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange) types.UpdateTask { if refRange != nil { from, to := refRange.From, refRange.To args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"} args = append(args, "--") if path := self.c.Modes().Filtering.GetPath(); path != "" { args = append(args, path) } cmdObj := self.c.Git().Diff.DiffCmdObj(args) task := types.NewRunPtyTask(cmdObj.GetCmd()) task.Prefix = style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName()) return task } cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Modes().Filtering.GetPath()) return types.NewRunPtyTask(cmdObj.GetCmd()) } func (self *DiffHelper) ExitDiffMode() error { self.c.Modes().Diffing = diffing.New() return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) } func (self *DiffHelper) RenderDiff() { args := self.DiffArgs() cmdObj := self.c.Git().Diff.DiffCmdObj(args) task := types.NewRunPtyTask(cmdObj.GetCmd()) task.Prefix = style.FgMagenta.Sprintf( "%s %s\n\n", self.c.Tr.ShowingGitDiff, "git diff "+strings.Join(args, " "), ) self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Diff", SubTitle: self.IgnoringWhitespaceSubTitle(), Task: task, }, }) } // CurrentDiffTerminals returns the current diff terminals of the currently selected item. // in the case of a branch it returns both the branch and it's upstream name, // which becomes an option when you bring up the diff menu, but when you're just // flicking through branches it will be using the local branch name. func (self *DiffHelper) CurrentDiffTerminals() []string { c := self.c.Context().CurrentSide() if c.GetKey() == "" { return nil } switch v := c.(type) { case types.DiffableContext: return v.GetDiffTerminals() } return nil } func (self *DiffHelper) currentDiffTerminal() string { names := self.CurrentDiffTerminals() if len(names) == 0 { return "" } return names[0] } func (self *DiffHelper) currentlySelectedFilename() string { currentContext := self.c.Context().Current() switch currentContext := currentContext.(type) { case types.IListContext: if lo.Contains([]types.ContextKey{context.FILES_CONTEXT_KEY, context.COMMIT_FILES_CONTEXT_KEY}, currentContext.GetKey()) { return currentContext.GetSelectedItemId() } } return "" } func (self *DiffHelper) WithDiffModeCheck(f func()) { if self.c.Modes().Diffing.Active() { self.RenderDiff() } else { f() } } func (self *DiffHelper) IgnoringWhitespaceSubTitle() string { if self.c.GetAppState().IgnoreWhitespaceInDiffView { return self.c.Tr.IgnoreWhitespaceDiffViewSubTitle } return "" } func (self *DiffHelper) OpenDiffToolForRef(selectedRef types.Ref) error { to := selectedRef.RefName() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff("") _, err := self.c.RunSubprocess(self.c.Git().Diff.OpenDiffToolCmdObj( git_commands.DiffToolCmdOptions{ Filepath: ".", FromCommit: from, ToCommit: to, Reverse: reverse, IsDirectory: true, Staged: false, })) return err } // AdjustLineNumber is used to adjust a line number in the diff that's currently // being viewed, so that it corresponds to the line number in the actual working // copy state of the file. It is used when clicking on a delta hyperlink in a // diff, or when pressing `e` in the staging or patch building panels. It works // by getting a diff of what's being viewed in the main view against the working // copy, and then using that diff to adjust the line number. // path is the file path of the file being viewed // linenumber is the line number to adjust (one-based) // viewname is the name of the view that shows the diff. We need to pass it // because the diff adjustment is slightly different depending on which view is // showing the diff. func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname string) int { switch viewname { case "main", "patchBuilding": if diffableContext, ok := self.c.Context().CurrentSide().(types.DiffableContext); ok { ref := diffableContext.RefForAdjustingLineNumberInDiff() if len(ref) != 0 { return self.adjustLineNumber(linenumber, ref, "--", path) } } // if the type cast to DiffableContext returns false, we are in the // unstaged changes view of the Files panel; no need to adjust line // numbers in this case case "secondary", "stagingSecondary": return self.adjustLineNumber(linenumber, "--", path) } return linenumber } func (self *DiffHelper) adjustLineNumber(linenumber int, diffArgs ...string) int { args := append([]string{"--unified=0"}, diffArgs...) diff, err := self.c.Git().Diff.GetDiff(false, args...) if err != nil { return linenumber } patch := patch.Parse(diff) return patch.AdjustLineNumber(linenumber) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/files_helper.go000066400000000000000000000041051500612110400242750ustar00rootroot00000000000000package helpers import ( "path/filepath" "github.com/samber/lo" ) type FilesHelper struct { c *HelperCommon } func NewFilesHelper(c *HelperCommon) *FilesHelper { return &FilesHelper{ c: c, } } func (self *FilesHelper) EditFiles(filenames []string) error { absPaths := lo.Map(filenames, func(filename string, _ int) string { absPath, err := filepath.Abs(filename) if err != nil { return filename } return absPath }) cmdStr, suspend := self.c.Git().File.GetEditCmdStr(absPaths) return self.callEditor(cmdStr, suspend) } func (self *FilesHelper) EditFileAtLine(filename string, lineNumber int) error { absPath, err := filepath.Abs(filename) if err != nil { return err } cmdStr, suspend := self.c.Git().File.GetEditAtLineCmdStr(absPath, lineNumber) return self.callEditor(cmdStr, suspend) } func (self *FilesHelper) EditFileAtLineAndWait(filename string, lineNumber int) error { absPath, err := filepath.Abs(filename) if err != nil { return err } cmdStr := self.c.Git().File.GetEditAtLineAndWaitCmdStr(absPath, lineNumber) // Always suspend, regardless of the value of the suspend config, // since we want to prevent interacting with the UI until the editor // returns, even if the editor doesn't use the terminal return self.callEditor(cmdStr, true) } func (self *FilesHelper) OpenDirInEditor(path string) error { absPath, err := filepath.Abs(path) if err != nil { return err } cmdStr, suspend := self.c.Git().File.GetOpenDirInEditorCmdStr(absPath) return self.callEditor(cmdStr, suspend) } func (self *FilesHelper) callEditor(cmdStr string, suspend bool) error { if suspend { return self.c.RunSubprocessAndRefresh( self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile), ) } return self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile).Run() } func (self *FilesHelper) OpenFile(filename string) error { absPath, err := filepath.Abs(filename) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.OpenFile) if err := self.c.OS().OpenFile(absPath); err != nil { return err } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/fixup_helper.go000066400000000000000000000243001500612110400243250ustar00rootroot00000000000000package helpers import ( "errors" "fmt" "regexp" "strings" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "golang.org/x/sync/errgroup" ) type FixupHelper struct { c *HelperCommon } func NewFixupHelper( c *HelperCommon, ) *FixupHelper { return &FixupHelper{ c: c, } } // hunk describes the lines in a diff hunk. Used for two distinct cases: // // - when the hunk contains some deleted lines. Because we're diffing with a // context of 0, all deleted lines always come first, and then the added lines // (if any). In this case, numLines is only the number of deleted lines, we // ignore whether there are also some added lines in the hunk, as this is not // relevant for our algorithm. // // - when the hunk contains only added lines, in which case (obviously) numLines // is the number of added lines. type hunk struct { filename string startLineIdx int numLines int } func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error { diff, hasStagedChanges, err := self.getDiff() if err != nil { return err } deletedLineHunks, addedLineHunks := parseDiff(diff) commits := self.c.Model().Commits var hashes []string warnAboutAddedLines := false if len(deletedLineHunks) > 0 { hashes, err = self.blameDeletedLines(deletedLineHunks) warnAboutAddedLines = len(addedLineHunks) > 0 } else if len(addedLineHunks) > 0 { hashes, err = self.blameAddedLines(commits, addedLineHunks) } else { return errors.New(self.c.Tr.NoChangedFiles) } if err != nil { return err } if len(hashes) == 0 { // This should never happen return errors.New(self.c.Tr.NoBaseCommitsFound) } // If a commit can't be found, and the last known commit is already merged, // we know that the commit we're looking for is also merged. Otherwise we // can't tell. notFoundMeansMerged := len(commits) > 0 && commits[len(commits)-1].Status == models.StatusMerged const ( MERGED int = iota NOT_MERGED CANNOT_TELL ) // Group the hashes into buckets by merged status hashGroups := lo.GroupBy(hashes, func(hash string) int { commit, _, ok := self.findCommit(commits, hash) if ok { return lo.Ternary(commit.Status == models.StatusMerged, MERGED, NOT_MERGED) } return lo.Ternary(notFoundMeansMerged, MERGED, CANNOT_TELL) }) if len(hashGroups[CANNOT_TELL]) > 0 { // If we have any commits that we can't tell if they're merged, just // show the generic "not in current view" error. This can only happen if // a feature branch has more than 300 commits, or there is no main // branch. Both are so unlikely that we don't bother returning a more // detailed error message (e.g. we could say something about the commits // that *are* in the current branch, but it's not worth it). return errors.New(self.c.Tr.BaseCommitIsNotInCurrentView) } if len(hashGroups[NOT_MERGED]) == 0 { // If all the commits are merged, show the "already on main branch" // error. It isn't worth doing a detailed report of which commits we // found. return errors.New(self.c.Tr.BaseCommitIsAlreadyOnMainBranch) } if len(hashGroups[NOT_MERGED]) > 1 { // If there are multiple commits that could be the base commit, list // them in the error message. But only the candidates from the current // branch, not including any that are already merged. subjects, err := self.c.Git().Commit.GetHashesAndCommitMessagesFirstLine(hashGroups[NOT_MERGED]) if err != nil { return err } message := lo.Ternary(hasStagedChanges, self.c.Tr.MultipleBaseCommitsFoundStaged, self.c.Tr.MultipleBaseCommitsFoundUnstaged) return fmt.Errorf("%s\n\n%s", message, subjects) } // At this point we know that the NOT_MERGED bucket has exactly one commit, // and that's the one we want to select. _, index, _ := self.findCommit(commits, hashGroups[NOT_MERGED][0]) doIt := func() error { if !hasStagedChanges { if err := self.c.Git().WorkingTree.StageAll(); err != nil { return err } _ = self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) } self.c.Contexts().LocalCommits.SetSelection(index) self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) return nil } if warnAboutAddedLines { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.FindBaseCommitForFixup, Prompt: self.c.Tr.HunksWithOnlyAddedLinesWarning, HandleConfirm: func() error { return doIt() }, }) return nil } return doIt() } func (self *FixupHelper) getDiff() (string, bool, error) { args := []string{"-U0", "--ignore-submodules=all", "HEAD", "--"} // Try staged changes first hasStagedChanges := true diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).RunWithOutput() if err == nil && diff == "" { hasStagedChanges = false // If there are no staged changes, try unstaged changes diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).RunWithOutput() } return diff, hasStagedChanges, err } // Parse the diff output into hunks, and return two lists of hunks: the first // are ones that contain deleted lines, the second are ones that contain only // added lines. func parseDiff(diff string) ([]*hunk, []*hunk) { lines := strings.Split(strings.TrimSuffix(diff, "\n"), "\n") deletedLineHunks := []*hunk{} addedLineHunks := []*hunk{} hunkHeaderRegexp := regexp.MustCompile(`@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@`) var filename string var currentHunk *hunk numDeletedLines := 0 numAddedLines := 0 finishHunk := func() { if currentHunk != nil { if numDeletedLines > 0 { currentHunk.numLines = numDeletedLines deletedLineHunks = append(deletedLineHunks, currentHunk) } else if numAddedLines > 0 { currentHunk.numLines = numAddedLines addedLineHunks = append(addedLineHunks, currentHunk) } } numDeletedLines = 0 numAddedLines = 0 } for _, line := range lines { if strings.HasPrefix(line, "diff --git") { finishHunk() currentHunk = nil } else if strings.HasPrefix(line, "--- ") { // For some reason, the line ends with a tab character if the file // name contains spaces filename = strings.TrimRight(line[6:], "\t") } else if strings.HasPrefix(line, "@@ ") { finishHunk() match := hunkHeaderRegexp.FindStringSubmatch(line) startIdx := utils.MustConvertToInt(match[1]) currentHunk = &hunk{filename, startIdx, 0} } else if currentHunk != nil && line[0] == '-' { numDeletedLines++ } else if currentHunk != nil && line[0] == '+' { numAddedLines++ } } finishHunk() return deletedLineHunks, addedLineHunks } // returns the list of commit hashes that introduced the lines which have now been deleted func (self *FixupHelper) blameDeletedLines(deletedLineHunks []*hunk) ([]string, error) { errg := errgroup.Group{} hashChan := make(chan string) for _, h := range deletedLineHunks { errg.Go(func() error { blameOutput, err := self.c.Git().Blame.BlameLineRange(h.filename, "HEAD", h.startLineIdx, h.numLines) if err != nil { return err } blameLines := strings.Split(strings.TrimSuffix(blameOutput, "\n"), "\n") for _, line := range blameLines { hashChan <- strings.Split(line, " ")[0] } return nil }) } go func() { // We don't care about the error here, we'll check it later (in the // return statement below). Here we only wait for all the goroutines to // finish so that we can close the channel. _ = errg.Wait() close(hashChan) }() result := set.New[string]() for hash := range hashChan { result.Add(hash) } return result.ToSlice(), errg.Wait() } func (self *FixupHelper) blameAddedLines(commits []*models.Commit, addedLineHunks []*hunk) ([]string, error) { errg := errgroup.Group{} hashesChan := make(chan []string) for _, h := range addedLineHunks { errg.Go(func() error { result := make([]string, 0, 2) appendBlamedLine := func(blameOutput string) { blameLines := strings.Split(strings.TrimSuffix(blameOutput, "\n"), "\n") if len(blameLines) == 1 { result = append(result, strings.Split(blameLines[0], " ")[0]) } } // Blame the line before this hunk, if there is one if h.startLineIdx > 0 { blameOutput, err := self.c.Git().Blame.BlameLineRange(h.filename, "HEAD", h.startLineIdx, 1) if err != nil { return err } appendBlamedLine(blameOutput) } // Blame the line after this hunk. We don't know how many lines the // file has, so we can't check if there is a line after the hunk; // let the error tell us. blameOutput, err := self.c.Git().Blame.BlameLineRange(h.filename, "HEAD", h.startLineIdx+1, 1) if err != nil { // If this fails, we're probably at the end of the file (we // could have checked this beforehand, but it's expensive). If // there was a line before this hunk, this is fine, we'll just // return that one; if not, the hunk encompasses the entire // file, and we can't blame the lines before and after the hunk. // This is an error. if h.startLineIdx == 0 { return errors.New("Entire file") // TODO i18n } } else { appendBlamedLine(blameOutput) } hashesChan <- result return nil }) } go func() { // We don't care about the error here, we'll check it later (in the // return statement below). Here we only wait for all the goroutines to // finish so that we can close the channel. _ = errg.Wait() close(hashesChan) }() result := set.New[string]() for hashes := range hashesChan { if len(hashes) == 1 { result.Add(hashes[0]) } else if len(hashes) > 1 { if hashes[0] == hashes[1] { result.Add(hashes[0]) } else { _, index1, ok1 := self.findCommit(commits, hashes[0]) _, index2, ok2 := self.findCommit(commits, hashes[1]) if ok1 && ok2 { result.Add(lo.Ternary(index1 < index2, hashes[0], hashes[1])) } else if ok1 { result.Add(hashes[0]) } else if ok2 { result.Add(hashes[1]) } else { return nil, errors.New(self.c.Tr.NoBaseCommitsFound) } } } } return result.ToSlice(), errg.Wait() } func (self *FixupHelper) findCommit(commits []*models.Commit, hash string) (*models.Commit, int, bool) { return lo.FindIndexOf(commits, func(commit *models.Commit) bool { return commit.Hash() == hash }) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/fixup_helper_test.go000066400000000000000000000051041500612110400253650ustar00rootroot00000000000000package helpers import ( "testing" "github.com/stretchr/testify/assert" ) func TestFixupHelper_parseDiff(t *testing.T) { scenarios := []struct { name string diff string expectedDeletedLineHunks []*hunk expectedAddedLineHunks []*hunk }{ { name: "no diff", diff: "", expectedDeletedLineHunks: []*hunk{}, expectedAddedLineHunks: []*hunk{}, }, { name: "hunk with only deleted lines", diff: ` diff --git a/file1.txt b/file1.txt index 9ce8efb33..aaf2a4666 100644 --- a/file1.txt +++ b/file1.txt @@ -3 +2,0 @@ bbb -xxx `, expectedDeletedLineHunks: []*hunk{ { filename: "file1.txt", startLineIdx: 3, numLines: 1, }, }, expectedAddedLineHunks: []*hunk{}, }, { name: "hunk with deleted and added lines", diff: ` diff --git a/file1.txt b/file1.txt index 9ce8efb33..eb246cf98 100644 --- a/file1.txt +++ b/file1.txt @@ -3 +3 @@ bbb -xxx +yyy `, expectedDeletedLineHunks: []*hunk{ { filename: "file1.txt", startLineIdx: 3, numLines: 1, }, }, expectedAddedLineHunks: []*hunk{}, }, { name: "hunk with only added lines", diff: ` diff --git a/file1.txt b/file1.txt index 9ce8efb33..fb5e469e7 100644 --- a/file1.txt +++ b/file1.txt @@ -4,0 +5,2 @@ ddd +xxx +yyy `, expectedDeletedLineHunks: []*hunk{}, expectedAddedLineHunks: []*hunk{ { filename: "file1.txt", startLineIdx: 4, numLines: 2, }, }, }, { name: "several hunks in different files", diff: ` diff --git a/file1.txt b/file1.txt index 9ce8efb33..0632e41b0 100644 --- a/file1.txt +++ b/file1.txt @@ -2 +1,0 @@ aaa -bbb @@ -4 +3 @@ ccc -ddd +xxx @@ -6,0 +6 @@ fff +zzz diff --git a/file2.txt b/file2.txt index 9ce8efb33..0632e41b0 100644 --- a/file2.txt +++ b/file2.txt @@ -0,3 +1,0 @@ aaa -aaa -bbb -ccc `, expectedDeletedLineHunks: []*hunk{ { filename: "file1.txt", startLineIdx: 2, numLines: 1, }, { filename: "file1.txt", startLineIdx: 4, numLines: 1, }, { filename: "file2.txt", startLineIdx: 0, numLines: 3, }, }, expectedAddedLineHunks: []*hunk{ { filename: "file1.txt", startLineIdx: 6, numLines: 1, }, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { deletedLineHunks, addedLineHunks := parseDiff(s.diff) assert.Equal(t, s.expectedDeletedLineHunks, deletedLineHunks) assert.Equal(t, s.expectedAddedLineHunks, addedLineHunks) }) } } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/gpg_helper.go000066400000000000000000000037031500612110400237530ustar00rootroot00000000000000package helpers import ( "fmt" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type GpgHelper struct { c *HelperCommon } func NewGpgHelper(c *HelperCommon) *GpgHelper { return &GpgHelper{ c: c, } } // Currently there is a bug where if we switch to a subprocess from within // WithWaitingStatus we get stuck there and can't return to lazygit. We could // fix this bug, or just stop running subprocesses from within there, given that // we don't need to see a loading status if we're in a subprocess. func (self *GpgHelper) WithGpgHandling(cmdObj oscommands.ICmdObj, configKey git_commands.GpgConfigKey, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error { useSubprocess := self.c.Git().Config.NeedsGpgSubprocess(configKey) if useSubprocess { success, err := self.c.RunSubprocess(cmdObj) if success && onSuccess != nil { if err := onSuccess(); err != nil { return err } } if err := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}); err != nil { return err } return err } else { return self.runAndStream(cmdObj, waitingStatus, onSuccess, refreshScope) } } func (self *GpgHelper) runAndStream(cmdObj oscommands.ICmdObj, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error { return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error { if err := cmdObj.StreamOutput().Run(); err != nil { _ = self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}) return fmt.Errorf( self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu, ) } if onSuccess != nil { if err := onSuccess(); err != nil { return err } } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}) }) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/helpers.go000066400000000000000000000055561500612110400233110ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type HelperCommon struct { *common.Common types.IGuiCommon IGetContexts } type IGetContexts interface { Contexts() *context.ContextTree } type Helpers struct { Refs *RefsHelper Bisect *BisectHelper Suggestions *SuggestionsHelper Files *FilesHelper WorkingTree *WorkingTreeHelper BranchesHelper *BranchesHelper Tags *TagsHelper MergeAndRebase *MergeAndRebaseHelper MergeConflicts *MergeConflictsHelper CherryPick *CherryPickHelper Host *HostHelper PatchBuilding *PatchBuildingHelper Staging *StagingHelper GPG *GpgHelper Upstream *UpstreamHelper AmendHelper *AmendHelper FixupHelper *FixupHelper Commits *CommitsHelper Snake *SnakeHelper // lives in context package because our contexts need it to render to main Diff *DiffHelper Repos *ReposHelper RecordDirectory *RecordDirectoryHelper Update *UpdateHelper Window *WindowHelper View *ViewHelper Refresh *RefreshHelper Confirmation *ConfirmationHelper Mode *ModeHelper AppStatus *AppStatusHelper InlineStatus *InlineStatusHelper WindowArrangement *WindowArrangementHelper Search *SearchHelper Worktree *WorktreeHelper SubCommits *SubCommitsHelper } func NewStubHelpers() *Helpers { return &Helpers{ Refs: &RefsHelper{}, Bisect: &BisectHelper{}, Suggestions: &SuggestionsHelper{}, Files: &FilesHelper{}, WorkingTree: &WorkingTreeHelper{}, Tags: &TagsHelper{}, MergeAndRebase: &MergeAndRebaseHelper{}, MergeConflicts: &MergeConflictsHelper{}, CherryPick: &CherryPickHelper{}, Host: &HostHelper{}, PatchBuilding: &PatchBuildingHelper{}, Staging: &StagingHelper{}, GPG: &GpgHelper{}, Upstream: &UpstreamHelper{}, AmendHelper: &AmendHelper{}, FixupHelper: &FixupHelper{}, Commits: &CommitsHelper{}, Snake: &SnakeHelper{}, Diff: &DiffHelper{}, Repos: &ReposHelper{}, RecordDirectory: &RecordDirectoryHelper{}, Update: &UpdateHelper{}, Window: &WindowHelper{}, View: &ViewHelper{}, Refresh: &RefreshHelper{}, Confirmation: &ConfirmationHelper{}, Mode: &ModeHelper{}, AppStatus: &AppStatusHelper{}, InlineStatus: &InlineStatusHelper{}, WindowArrangement: &WindowArrangementHelper{}, Search: &SearchHelper{}, Worktree: &WorktreeHelper{}, SubCommits: &SubCommitsHelper{}, } } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/host_helper.go000066400000000000000000000022021500612110400241440ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" ) // this helper just wraps our hosting_service package type HostHelper struct { c *HelperCommon } func NewHostHelper( c *HelperCommon, ) *HostHelper { return &HostHelper{ c: c, } } func (self *HostHelper) GetPullRequestURL(from string, to string) (string, error) { mgr, err := self.getHostingServiceMgr() if err != nil { return "", err } return mgr.GetPullRequestURL(from, to) } func (self *HostHelper) GetCommitURL(commitHash string) (string, error) { mgr, err := self.getHostingServiceMgr() if err != nil { return "", err } return mgr.GetCommitURL(commitHash) } // getting this on every request rather than storing it in state in case our remoteURL changes // from one invocation to the next. func (self *HostHelper) getHostingServiceMgr() (*hosting_service.HostingServiceMgr, error) { remoteUrl, err := self.c.Git().Remote.GetRemoteURL("origin") if err != nil { return nil, err } configServices := self.c.UserConfig().Services return hosting_service.NewHostingServiceMgr(self.c.Log, self.c.Tr, remoteUrl, configServices), nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/inline_status_helper.go000066400000000000000000000111741500612110400260600ustar00rootroot00000000000000package helpers import ( "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" ) type InlineStatusHelper struct { c *HelperCommon windowHelper *WindowHelper contextsWithInlineStatus map[types.ContextKey]*inlineStatusInfo mutex *deadlock.Mutex } func NewInlineStatusHelper(c *HelperCommon, windowHelper *WindowHelper) *InlineStatusHelper { return &InlineStatusHelper{ c: c, windowHelper: windowHelper, contextsWithInlineStatus: make(map[types.ContextKey]*inlineStatusInfo), mutex: &deadlock.Mutex{}, } } type InlineStatusOpts struct { Item types.HasUrn Operation types.ItemOperation ContextKey types.ContextKey } type inlineStatusInfo struct { refCount int stop chan struct{} } // A custom task for WithInlineStatus calls; it wraps the original one and // hides the status whenever the task is paused, and shows it again when // continued. type inlineStatusHelperTask struct { gocui.Task inlineStatusHelper *InlineStatusHelper opts InlineStatusOpts } // poor man's version of explicitly saying that struct X implements interface Y var _ gocui.Task = inlineStatusHelperTask{} func (self inlineStatusHelperTask) Pause() { self.inlineStatusHelper.stop(self.opts) self.Task.Pause() self.inlineStatusHelper.renderContext(self.opts.ContextKey) } func (self inlineStatusHelperTask) Continue() { self.Task.Continue() self.inlineStatusHelper.start(self.opts) } func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(gocui.Task) error) { context := self.c.ContextForKey(opts.ContextKey).(types.IListContext) view := context.GetView() visible := view.Visible && self.windowHelper.TopViewInWindow(context.GetWindowName(), false) == view if visible && context.IsItemVisible(opts.Item) { self.c.OnWorker(func(task gocui.Task) error { self.start(opts) defer self.stop(opts) return f(inlineStatusHelperTask{task, self, opts}) }) } else { message := presentation.ItemOperationToString(opts.Operation, self.c.Tr) _ = self.c.WithWaitingStatus(message, func(t gocui.Task) error { // We still need to set the item operation, because it might be used // for other (non-presentation) purposes self.c.State().SetItemOperation(opts.Item, opts.Operation) defer self.c.State().ClearItemOperation(opts.Item) return f(t) }) } } func (self *InlineStatusHelper) start(opts InlineStatusOpts) { self.c.State().SetItemOperation(opts.Item, opts.Operation) self.mutex.Lock() defer self.mutex.Unlock() info := self.contextsWithInlineStatus[opts.ContextKey] if info == nil { info = &inlineStatusInfo{refCount: 0, stop: make(chan struct{})} self.contextsWithInlineStatus[opts.ContextKey] = info go utils.Safe(func() { ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) defer ticker.Stop() outer: for { select { case <-ticker.C: self.renderContext(opts.ContextKey) case <-info.stop: break outer } } }) } info.refCount++ } func (self *InlineStatusHelper) stop(opts InlineStatusOpts) { self.mutex.Lock() if info := self.contextsWithInlineStatus[opts.ContextKey]; info != nil { info.refCount-- if info.refCount <= 0 { info.stop <- struct{}{} delete(self.contextsWithInlineStatus, opts.ContextKey) } } self.mutex.Unlock() self.c.State().ClearItemOperation(opts.Item) // When recording a demo we need to re-render the context again here to // remove the inline status. In normal usage we don't want to do this // because in the case of pushing a branch this would first reveal the ↑3↓7 // status from before the push for a brief moment, to be replaced by a green // checkmark a moment later when the async refresh is done. This looks // jarring, so normally we rely on the async refresh to redraw with the // status removed. (In some rare cases, where there's no refresh at all, we // need to redraw manually in the controller; see TagsController.push() for // an example.) // // In demos, however, we turn all async refreshes into sync ones, because // this looks better in demos. In this case the refresh happens while the // status is still set, so we need to render again after removing it. if self.c.InDemo() { self.renderContext(opts.ContextKey) } } func (self *InlineStatusHelper) renderContext(contextKey types.ContextKey) { self.c.OnUIThread(func() error { self.c.ContextForKey(contextKey).HandleRender() return nil }) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/merge_and_rebase_helper.go000066400000000000000000000335211500612110400264410ustar00rootroot00000000000000package helpers import ( "errors" "fmt" "os" "path/filepath" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) type MergeAndRebaseHelper struct { c *HelperCommon } func NewMergeAndRebaseHelper( c *HelperCommon, ) *MergeAndRebaseHelper { return &MergeAndRebaseHelper{ c: c, } } type RebaseOption string const ( REBASE_OPTION_CONTINUE string = "continue" REBASE_OPTION_ABORT string = "abort" REBASE_OPTION_SKIP string = "skip" ) func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error { type optionAndKey struct { option string key types.Key } options := []optionAndKey{ {option: REBASE_OPTION_CONTINUE, key: 'c'}, {option: REBASE_OPTION_ABORT, key: 'a'}, } if self.c.Git().Status.WorkingTreeState().CanSkip() { options = append(options, optionAndKey{ option: REBASE_OPTION_SKIP, key: 's', }) } menuItems := lo.Map(options, func(row optionAndKey, _ int) *types.MenuItem { return &types.MenuItem{ Label: row.option, OnPress: func() error { return self.genericMergeCommand(row.option) }, Key: row.key, } }) title := self.c.Git().Status.WorkingTreeState().OptionsMenuTitle(self.c.Tr) return self.c.Menu(types.CreateMenuOptions{Title: title, Items: menuItems}) } func (self *MergeAndRebaseHelper) ContinueRebase() error { return self.genericMergeCommand(REBASE_OPTION_CONTINUE) } func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { status := self.c.Git().Status.WorkingTreeState() if status.None() { return errors.New(self.c.Tr.NotMergingOrRebasing) } self.c.LogAction(fmt.Sprintf("Merge/Rebase: %s", command)) effectiveStatus := status.Effective() if effectiveStatus == models.WORKING_TREE_STATE_REBASING { todoFile, err := os.ReadFile( filepath.Join(self.c.Git().RepoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"), ) if err != nil { if !os.IsNotExist(err) { return err } } else { self.c.LogCommand(string(todoFile), false) } } commandType := status.CommandName() // we should end up with a command like 'git merge --continue' // it's impossible for a rebase to require a commit so we'll use a subprocess only if it's a merge needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) || // but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build // tasks whose output the user will want to see in the terminal (effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos()) if needsSubprocess { // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction return self.c.RunSubprocessAndRefresh( self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command), ) } result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) if err := self.CheckMergeOrRebase(result); err != nil { return err } return nil } func (self *MergeAndRebaseHelper) hasExecTodos() bool { for _, commit := range self.c.Model().Commits { if !commit.IsTODO() { break } if commit.Action == todo.Exec { return true } } return false } var conflictStrings = []string{ "Failed to merge in the changes", "When you have resolved this problem", "fix conflicts", "Resolve all conflicts manually", "Merge conflict in file", "hint: after resolving the conflicts", "CONFLICT (content):", } func isMergeConflictErr(errStr string) bool { for _, str := range conflictStrings { if strings.Contains(errStr, str) { return true } } return false } func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error { if err := self.c.Refresh(refreshOptions); err != nil { return err } if result == nil { return nil } else if strings.Contains(result.Error(), "No changes - did you forget to use") { return self.genericMergeCommand(REBASE_OPTION_SKIP) } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") { return self.genericMergeCommand(REBASE_OPTION_CONTINUE) } else if strings.Contains(result.Error(), "No rebase in progress?") { // assume in this case that we're already done return nil } else { return self.CheckForConflicts(result) } } func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC}) } func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error { if result == nil { return nil } if isMergeConflictErr(result.Error()) { return self.PromptForConflictHandling() } return result } func (self *MergeAndRebaseHelper) PromptForConflictHandling() error { mode := self.c.Git().Status.WorkingTreeState().CommandName() return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.FoundConflictsTitle, Items: []*types.MenuItem{ { Label: self.c.Tr.ViewConflictsMenuItem, OnPress: func() error { self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) return nil }, }, { Label: fmt.Sprintf(self.c.Tr.AbortMenuItem, mode), OnPress: func() error { return self.genericMergeCommand(REBASE_OPTION_ABORT) }, Key: 'a', }, }, HideCancel: true, }) } func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error { // prompt user to confirm that they want to abort, then do it mode := self.c.Git().Status.WorkingTreeState().CommandName() self.c.Confirm(types.ConfirmOpts{ Title: fmt.Sprintf(self.c.Tr.AbortTitle, mode), Prompt: fmt.Sprintf(self.c.Tr.AbortPrompt, mode), HandleConfirm: func() error { return self.genericMergeCommand(REBASE_OPTION_ABORT) }, }) return nil } // PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Continue, Prompt: fmt.Sprintf(self.c.Tr.ConflictsResolved, self.c.Git().Status.WorkingTreeState().CommandName()), HandleConfirm: func() error { // By the time we get here, we might have unstaged changes again, // e.g. if the user had to fix build errors after resolving the // conflicts, but after lazygit opened the prompt already. Ask again // to auto-stage these. // Need to refresh the files to be really sure if this is the case. // We would otherwise be relying on lazygit's auto-refresh on focus, // but this is not supported by all terminals or on all platforms. if err := self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, }); err != nil { return err } root := self.c.Contexts().Files.FileTreeViewModel.GetRoot() if root.GetHasUnstagedChanges() { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Continue, Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.StageAllFiles) if err := self.c.Git().WorkingTree.StageAll(); err != nil { return err } return self.genericMergeCommand(REBASE_OPTION_CONTINUE) }, }) return nil } return self.genericMergeCommand(REBASE_OPTION_CONTINUE) }, }) return nil } func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { checkedOutBranch := self.c.Model().Branches[0] checkedOutBranchName := checkedOutBranch.Name var disabledReason, baseBranchDisabledReason *types.DisabledReason if checkedOutBranchName == ref { disabledReason = &types.DisabledReason{Text: self.c.Tr.CantRebaseOntoSelf} } baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(checkedOutBranch, self.c.Model().MainBranches) if err != nil { return err } if baseBranch == "" { baseBranch = self.c.Tr.CouldNotDetermineBaseBranch baseBranchDisabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} } menuItems := []*types.MenuItem{ { Label: utils.ResolvePlaceholderString(self.c.Tr.SimpleRebase, map[string]string{"ref": ref}, ), Key: 's', DisabledReason: disabledReason, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(ref, baseCommit) } else { err = self.c.Git().Rebase.RebaseBranch(ref) } err = self.CheckMergeOrRebase(err) if err == nil { return self.ResetMarkedBaseCommit() } return err }) }, }, { Label: utils.ResolvePlaceholderString(self.c.Tr.InteractiveRebase, map[string]string{"ref": ref}, ), Key: 'i', DisabledReason: disabledReason, Tooltip: self.c.Tr.InteractiveRebaseTooltip, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit) } else { err = self.c.Git().Rebase.EditRebase(ref) } if err = self.CheckMergeOrRebase(err); err != nil { return err } if err = self.ResetMarkedBaseCommit(); err != nil { return err } self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) return nil }, }, { Label: utils.ResolvePlaceholderString(self.c.Tr.RebaseOntoBaseBranch, map[string]string{"baseBranch": ShortBranchName(baseBranch)}, ), Key: 'b', DisabledReason: baseBranchDisabledReason, Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(baseBranch, baseCommit) } else { err = self.c.Git().Rebase.RebaseBranch(baseBranch) } err = self.CheckMergeOrRebase(err) if err == nil { return self.ResetMarkedBaseCommit() } return err }) }, }, } title := utils.ResolvePlaceholderString( lo.Ternary(self.c.Modes().MarkedBaseCommit.GetHash() != "", self.c.Tr.RebasingFromBaseCommitTitle, self.c.Tr.RebasingTitle), map[string]string{ "checkedOutBranch": checkedOutBranchName, }, ) return self.c.Menu(types.CreateMenuOptions{ Title: title, Items: menuItems, }) } func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) error { if self.c.Git().Branch.IsHeadDetached() { return errors.New("Cannot merge branch in detached head state. You might have checked out a commit directly or a remote branch, in which case you should checkout the local branch you want to be on") } checkedOutBranchName := self.c.Model().Branches[0].Name if checkedOutBranchName == refName { return errors.New(self.c.Tr.CantMergeBranchIntoItself) } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Merge, Items: []*types.MenuItem{ { Label: self.c.Tr.RegularMerge, OnPress: self.RegularMerge(refName), Key: 'm', Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeTooltip, map[string]string{ "checkedOutBranch": checkedOutBranchName, "selectedBranch": refName, }, ), }, { Label: self.c.Tr.SquashMergeUncommittedTitle, OnPress: self.SquashMergeUncommitted(refName), Key: 's', Tooltip: utils.ResolvePlaceholderString( self.c.Tr.SquashMergeUncommitted, map[string]string{ "selectedBranch": refName, }, ), }, { Label: self.c.Tr.SquashMergeCommittedTitle, OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName), Key: 'S', Tooltip: utils.ResolvePlaceholderString( self.c.Tr.SquashMergeCommitted, map[string]string{ "checkedOutBranch": checkedOutBranchName, "selectedBranch": refName, }, ), }, }, }) } func (self *MergeAndRebaseHelper) RegularMerge(refName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.Merge) err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{}) return self.CheckMergeOrRebase(err) } } func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.SquashMerge) err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{Squash: true}) return self.CheckMergeOrRebase(err) } } func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranchName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.SquashMerge) err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{Squash: true}) if err = self.CheckMergeOrRebase(err); err != nil { return err } message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{ "selectedRef": refName, "currentBranch": checkedOutBranchName, }) err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run() if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) } } func (self *MergeAndRebaseHelper) ResetMarkedBaseCommit() error { self.c.Modes().MarkedBaseCommit.Reset() self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/merge_conflicts_helper.go000066400000000000000000000070131500612110400263370ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type MergeConflictsHelper struct { c *HelperCommon } func NewMergeConflictsHelper( c *HelperCommon, ) *MergeConflictsHelper { return &MergeConflictsHelper{ c: c, } } func (self *MergeConflictsHelper) SetMergeState(path string) (bool, error) { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() return self.setMergeStateWithoutLock(path) } func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, error) { content, err := self.c.Git().File.Cat(path) if err != nil { return false, err } if path != self.context().GetState().GetPath() { self.context().SetUserScrolling(false) } self.context().GetState().SetContent(content, path) return !self.context().GetState().NoConflicts(), nil } func (self *MergeConflictsHelper) ResetMergeState() { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() self.resetMergeState() } func (self *MergeConflictsHelper) resetMergeState() { self.context().SetUserScrolling(false) self.context().GetState().Reset() } func (self *MergeConflictsHelper) EscapeMerge() error { self.resetMergeState() // doing this in separate UI thread so that we're not still holding the lock by the time refresh the file self.c.OnUIThread(func() error { // There is a race condition here: refreshing the files scope can trigger the // confirmation context to be pushed if all conflicts are resolved (prompting // to continue the merge/rebase. In that case, we don't want to then push the // files context over it. // So long as both places call OnUIThread, we're fine. if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) { self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } return nil }) return nil } func (self *MergeConflictsHelper) SetConflictsAndRender(path string) (bool, error) { hasConflicts, err := self.setMergeStateWithoutLock(path) if err != nil { return false, err } if hasConflicts { return true, self.context().Render() } return false, nil } func (self *MergeConflictsHelper) SwitchToMerge(path string) error { if self.context().GetState().GetPath() != path { hasConflicts, err := self.SetMergeState(path) if err != nil { return err } if !hasConflicts { return nil } } self.c.Context().Push(self.c.Contexts().MergeConflicts, types.OnFocusOpts{}) return nil } func (self *MergeConflictsHelper) context() *context.MergeConflictsContext { return self.c.Contexts().MergeConflicts } func (self *MergeConflictsHelper) Render() { content := self.context().GetContentToRender() var task types.UpdateTask if self.context().IsUserScrolling() { task = types.NewRenderStringWithoutScrollTask(content) } else { originY := self.context().GetOriginY() task = types.NewRenderStringWithScrollTask(content, 0, originY) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().MergeConflicts, Main: &types.ViewUpdateOpts{ Task: task, }, }) } func (self *MergeConflictsHelper) RefreshMergeState() error { self.c.Contexts().MergeConflicts.GetMutex().Lock() defer self.c.Contexts().MergeConflicts.GetMutex().Unlock() if self.c.Context().Current().GetKey() != context.MERGE_CONFLICTS_CONTEXT_KEY { return nil } hasConflicts, err := self.SetConflictsAndRender(self.c.Contexts().MergeConflicts.GetState().GetPath()) if err != nil { return err } if !hasConflicts { return self.EscapeMerge() } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/mode_helper.go000066400000000000000000000117331500612110400241240ustar00rootroot00000000000000package helpers import ( "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type ModeHelper struct { c *HelperCommon diffHelper *DiffHelper patchBuildingHelper *PatchBuildingHelper cherryPickHelper *CherryPickHelper mergeAndRebaseHelper *MergeAndRebaseHelper bisectHelper *BisectHelper suppressRebasingMode bool } func NewModeHelper( c *HelperCommon, diffHelper *DiffHelper, patchBuildingHelper *PatchBuildingHelper, cherryPickHelper *CherryPickHelper, mergeAndRebaseHelper *MergeAndRebaseHelper, bisectHelper *BisectHelper, ) *ModeHelper { return &ModeHelper{ c: c, diffHelper: diffHelper, patchBuildingHelper: patchBuildingHelper, cherryPickHelper: cherryPickHelper, mergeAndRebaseHelper: mergeAndRebaseHelper, bisectHelper: bisectHelper, } } type ModeStatus struct { IsActive func() bool Description func() string Reset func() error } func (self *ModeHelper) Statuses() []ModeStatus { return []ModeStatus{ { IsActive: self.c.Modes().Diffing.Active, Description: func() string { return self.withResetButton( fmt.Sprintf( "%s %s", self.c.Tr.ShowingGitDiff, "git diff "+strings.Join(self.diffHelper.DiffArgs(), " "), ), style.FgMagenta, ) }, Reset: self.diffHelper.ExitDiffMode, }, { IsActive: self.c.Git().Patch.PatchBuilder.Active, Description: func() string { return self.withResetButton(self.c.Tr.BuildingPatch, style.FgYellow.SetBold()) }, Reset: self.patchBuildingHelper.Reset, }, { IsActive: self.c.Modes().Filtering.Active, Description: func() string { filterContent := lo.Ternary(self.c.Modes().Filtering.GetPath() != "", self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()) return self.withResetButton( fmt.Sprintf( "%s '%s'", self.c.Tr.FilteringBy, filterContent, ), style.FgRed, ) }, Reset: self.ExitFilterMode, }, { IsActive: self.c.Modes().MarkedBaseCommit.Active, Description: func() string { return self.withResetButton( self.c.Tr.MarkedBaseCommitStatus, style.FgCyan, ) }, Reset: self.mergeAndRebaseHelper.ResetMarkedBaseCommit, }, { IsActive: self.c.Modes().CherryPicking.Active, Description: func() string { copiedCount := len(self.c.Modes().CherryPicking.CherryPickedCommits) text := self.c.Tr.CommitsCopied if copiedCount == 1 { text = self.c.Tr.CommitCopied } return self.withResetButton( fmt.Sprintf( "%d %s", copiedCount, text, ), style.FgCyan, ) }, Reset: self.cherryPickHelper.Reset, }, { IsActive: func() bool { return !self.suppressRebasingMode && self.c.Git().Status.WorkingTreeState().Any() }, Description: func() string { workingTreeState := self.c.Git().Status.WorkingTreeState() return self.withResetButton( workingTreeState.Title(self.c.Tr), style.FgYellow, ) }, Reset: self.mergeAndRebaseHelper.AbortMergeOrRebaseWithConfirm, }, { IsActive: func() bool { return self.c.Model().BisectInfo.Started() }, Description: func() string { return self.withResetButton(self.c.Tr.Bisect.Bisecting, style.FgGreen) }, Reset: self.bisectHelper.Reset, }, } } func (self *ModeHelper) withResetButton(content string, textStyle style.TextStyle) string { return textStyle.Sprintf( "%s %s", content, style.AttrUnderline.Sprint(self.c.Tr.ResetInParentheses), ) } func (self *ModeHelper) GetActiveMode() (ModeStatus, bool) { return lo.Find(self.Statuses(), func(mode ModeStatus) bool { return mode.IsActive() }) } func (self *ModeHelper) IsAnyModeActive() bool { return lo.SomeBy(self.Statuses(), func(mode ModeStatus) bool { return mode.IsActive() }) } func (self *ModeHelper) ExitFilterMode() error { return self.ClearFiltering() } func (self *ModeHelper) ClearFiltering() error { selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash() self.c.Modes().Filtering.Reset() if self.c.State().GetRepoState().GetScreenMode() == types.SCREEN_HALF { self.c.State().GetRepoState().SetScreenMode(types.SCREEN_NORMAL) } return self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.COMMITS}, Then: func() error { // Find the commit that was last selected in filtering mode, and select it again after refreshing if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) { // If we couldn't find it (either because no commit was selected // in filtering mode, or because the commit is outside the // initial 300 range), go back to the commit that was selected // before we entered filtering self.c.Contexts().LocalCommits.SelectCommitByHash(self.c.Modes().Filtering.GetSelectedCommitHash()) } return nil }, }) } func (self *ModeHelper) SetSuppressRebasingMode(value bool) { self.suppressRebasingMode = value } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/patch_building_helper.go000066400000000000000000000056411500612110400261550ustar00rootroot00000000000000package helpers import ( "errors" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type PatchBuildingHelper struct { c *HelperCommon } func NewPatchBuildingHelper( c *HelperCommon, ) *PatchBuildingHelper { return &PatchBuildingHelper{ c: c, } } func (self *PatchBuildingHelper) ValidateNormalWorkingTreeState() (bool, error) { if self.c.Git().Status.WorkingTreeState().Any() { return false, errors.New(self.c.Tr.CantPatchWhileRebasingError) } return true, nil } // takes us from the patch building panel back to the commit files panel func (self *PatchBuildingHelper) Escape() { self.c.Context().Pop() } // kills the custom patch and returns us back to the commit files panel if needed func (self *PatchBuildingHelper) Reset() error { self.c.Git().Patch.PatchBuilder.Reset() if self.c.Context().CurrentStatic().GetKind() != types.SIDE_CONTEXT { self.Escape() } if err := self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.COMMIT_FILES}, }); err != nil { return err } // refreshing the current context so that the secondary panel is hidden if necessary. self.c.PostRefreshUpdate(self.c.Context().Current()) return nil } func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) { selectedLineIdx := -1 if opts.ClickedWindowName == "main" { selectedLineIdx = opts.ClickedViewLineIdx } if !self.c.Git().Patch.PatchBuilder.Active() { self.Escape() return } // get diff from commit file that's currently selected path := self.c.Contexts().CommitFiles.GetSelectedPath() if path == "" { return } from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, path, true) if err != nil { return } secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{ Filename: path, Plain: false, Reverse: false, TurnAddedFilesIntoDiffAgainstEmptyFile: true, }) context := self.c.Contexts().CustomPatchBuilder oldState := context.GetState() state := patch_exploring.NewState(diff, selectedLineIdx, context.GetView(), oldState) context.SetState(state) if state == nil { self.Escape() return } mainContent := context.GetContentToRender() self.c.Contexts().CustomPatchBuilder.FocusSelection() self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().PatchBuilding, Main: &types.ViewUpdateOpts{ Task: types.NewRenderStringWithoutScrollTask(mainContent), Title: self.c.Tr.Patch, }, Secondary: &types.ViewUpdateOpts{ Task: types.NewRenderStringWithoutScrollTask(secondaryDiff), Title: self.c.Tr.CustomPatch, }, }) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/record_directory_helper.go000066400000000000000000000017001500612110400265330ustar00rootroot00000000000000package helpers import ( "os" ) type RecordDirectoryHelper struct { c *HelperCommon } func NewRecordDirectoryHelper(c *HelperCommon) *RecordDirectoryHelper { return &RecordDirectoryHelper{ c: c, } } // when a user runs lazygit with the LAZYGIT_NEW_DIR_FILE env variable defined // we will write the current directory to that file on exit so that their // shell can then change to that directory. That means you don't get kicked // back to the directory that you started with. func (self *RecordDirectoryHelper) RecordCurrentDirectory() error { // determine current directory, set it in LAZYGIT_NEW_DIR_FILE dirName, err := os.Getwd() if err != nil { return err } return self.RecordDirectory(dirName) } func (self *RecordDirectoryHelper) RecordDirectory(dirName string) error { newDirFilePath := os.Getenv("LAZYGIT_NEW_DIR_FILE") if newDirFilePath == "" { return nil } return self.c.OS().CreateFileWithContent(newDirFilePath, dirName) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/refresh_helper.go000066400000000000000000000607331500612110400246420ustar00rootroot00000000000000package helpers import ( "strings" "sync" "time" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type RefreshHelper struct { c *HelperCommon refsHelper *RefsHelper mergeAndRebaseHelper *MergeAndRebaseHelper patchBuildingHelper *PatchBuildingHelper stagingHelper *StagingHelper mergeConflictsHelper *MergeConflictsHelper worktreeHelper *WorktreeHelper searchHelper *SearchHelper } func NewRefreshHelper( c *HelperCommon, refsHelper *RefsHelper, mergeAndRebaseHelper *MergeAndRebaseHelper, patchBuildingHelper *PatchBuildingHelper, stagingHelper *StagingHelper, mergeConflictsHelper *MergeConflictsHelper, worktreeHelper *WorktreeHelper, searchHelper *SearchHelper, ) *RefreshHelper { return &RefreshHelper{ c: c, refsHelper: refsHelper, mergeAndRebaseHelper: mergeAndRebaseHelper, patchBuildingHelper: patchBuildingHelper, stagingHelper: stagingHelper, mergeConflictsHelper: mergeConflictsHelper, worktreeHelper: worktreeHelper, searchHelper: searchHelper, } } func (self *RefreshHelper) Refresh(options types.RefreshOptions) error { if options.Mode == types.ASYNC && options.Then != nil { panic("RefreshOptions.Then doesn't work with mode ASYNC") } t := time.Now() defer func() { self.c.Log.Infof("Refresh took %s", time.Since(t)) }() if options.Scope == nil { self.c.Log.Infof( "refreshing all scopes in %s mode", getModeName(options.Mode), ) } else { self.c.Log.Infof( "refreshing the following scopes in %s mode: %s", getModeName(options.Mode), strings.Join(getScopeNames(options.Scope), ","), ) } f := func() error { var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { // not refreshing staging/patch-building unless explicitly requested because we only need // to refresh those while focused. scopeSet = set.NewFromSlice([]types.RefreshableView{ types.COMMITS, types.BRANCHES, types.FILES, types.STASH, types.REFLOG, types.TAGS, types.REMOTES, types.WORKTREES, types.STATUS, types.BISECT_INFO, types.STAGING, }) } else { scopeSet = set.NewFromSlice(options.Scope) } wg := sync.WaitGroup{} refresh := func(name string, f func()) { // if we're in a demo we don't want any async refreshes because // everything happens fast and it's better to have everything update // in the one frame if !self.c.InDemo() && options.Mode == types.ASYNC { self.c.OnWorker(func(t gocui.Task) error { f() return nil }) } else { wg.Add(1) go utils.Safe(func() { t := time.Now() defer wg.Done() f() self.c.Log.Infof("refreshed %s in %s", name, time.Since(t)) }) } } includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { // whenever we change commits, we should update branches because the upstream/downstream // counts can change. Whenever we change branches we should also change commits // e.g. in the case of switching branches. refresh("commits and commit files", self.refreshCommitsAndCommitFiles) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.AppState.LocalBranchSortOrder == "recency" { refresh("reflog and branches", func() { self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) }) } else { refresh("branches", func() { self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true) }) refresh("reflog", func() { _ = self.refreshReflogCommits() }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one // if we've asked specifically for rebase commits and not those other things refresh("rebase commits", func() { _ = self.refreshRebaseCommits() }) } if scopeSet.Includes(types.SUB_COMMITS) { refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit() }) } // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { refresh("commit files", func() { _ = self.refreshCommitFilesContext() }) } fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) { fileWg.Add(1) refresh("files", func() { _ = self.refreshFilesAndSubmodules() fileWg.Done() }) } if scopeSet.Includes(types.STASH) { refresh("stash", func() { self.refreshStashEntries() }) } if scopeSet.Includes(types.TAGS) { refresh("tags", func() { _ = self.refreshTags() }) } if scopeSet.Includes(types.REMOTES) { refresh("remotes", func() { _ = self.refreshRemotes() }) } if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { refresh("worktrees", func() { self.refreshWorktrees() }) } if scopeSet.Includes(types.STAGING) { refresh("staging", func() { fileWg.Wait() self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) }) } if scopeSet.Includes(types.PATCH_BUILDING) { refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) } if scopeSet.Includes(types.MERGE_CONFLICTS) || scopeSet.Includes(types.FILES) { refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState() }) } self.refreshStatus() wg.Wait() if options.Then != nil { if err := options.Then(); err != nil { return err } } return nil } if options.Mode == types.BLOCK_UI { self.c.OnUIThread(func() error { return f() }) return nil } return f() } func getScopeNames(scopes []types.RefreshableView) []string { scopeNameMap := map[types.RefreshableView]string{ types.COMMITS: "commits", types.BRANCHES: "branches", types.FILES: "files", types.SUBMODULES: "submodules", types.SUB_COMMITS: "subCommits", types.STASH: "stash", types.REFLOG: "reflog", types.TAGS: "tags", types.REMOTES: "remotes", types.WORKTREES: "worktrees", types.STATUS: "status", types.BISECT_INFO: "bisect", types.STAGING: "staging", types.MERGE_CONFLICTS: "mergeConflicts", } return lo.Map(scopes, func(scope types.RefreshableView, _ int) string { return scopeNameMap[scope] }) } func getModeName(mode types.RefreshMode) string { switch mode { case types.SYNC: return "sync" case types.ASYNC: return "async" case types.BLOCK_UI: return "block-ui" default: return "unknown mode" } } // during startup, the bottleneck is fetching the reflog entries. We need these // on startup to sort the branches by recency. So we have two phases: INITIAL, and COMPLETE. // In the initial phase we don't get any reflog commits, but we asynchronously get them // and refresh the branches after that func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: self.c.OnWorker(func(_ gocui.Task) error { _ = self.refreshReflogCommits() self.refreshBranches(false, true, true) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) case types.COMPLETE: _ = self.refreshReflogCommits() } } func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { loadBehindCounts := self.c.State().GetRepoState().GetStartupStage() == types.COMPLETE self.refreshReflogCommitsConsideringStartup() self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts) } func (self *RefreshHelper) refreshCommitsAndCommitFiles() { _ = self.refreshCommitsWithLimit() ctx := self.c.Contexts().CommitFiles.GetParentContext() if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. // However if we've just added a brand new commit, it pushes the list down by one and so we would end up // showing the contents of a different commit than the one we initially entered. // Ideally we would know when to refresh the commit files context and when not to, // or perhaps we could just pop that context off the stack whenever cycling windows. // For now the awkwardness remains. commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() self.c.Contexts().CommitFiles.ReInit(commit, refRange) _ = self.refreshCommitFilesContext() } } } func (self *RefreshHelper) determineCheckedOutBranchName() string { if rebasedBranch := self.c.Git().Status.BranchBeingRebased(); rebasedBranch != "" { // During a rebase we're on a detached head, so cannot determine the // branch name in the usual way. We need to read it from the // ".git/rebase-merge/head-name" file instead. return strings.TrimPrefix(rebasedBranch, "refs/heads/") } if bisectInfo := self.c.Git().Bisect.GetInfo(); bisectInfo.Bisecting() && bisectInfo.GetStartHash() != "" { // Likewise, when we're bisecting we're on a detached head as well. In // this case we read the branch name from the ".git/BISECT_START" file. return bisectInfo.GetStartHash() } // In all other cases, get the branch name by asking git what branch is // checked out. Note that if we're on a detached head (for reasons other // than rebasing or bisecting, i.e. it was explicitly checked out), then // this will return its hash. if branchName, err := self.c.Git().Branch.CurrentBranchName(); err == nil { return branchName } // Should never get here unless the working copy is corrupt return "" } func (self *RefreshHelper) refreshCommitsWithLimit() error { self.c.Mutexes().LocalCommitsMutex.Lock() defer self.c.Mutexes().LocalCommitsMutex.Unlock() checkedOutBranchName := self.determineCheckedOutBranchName() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: self.c.Contexts().LocalCommits.GetLimitCommits(), FilterPath: self.c.Modes().Filtering.GetPath(), FilterAuthor: self.c.Modes().Filtering.GetAuthor(), IncludeRebaseCommits: true, RefName: self.refForLog(), RefForPushedStatus: checkedOutBranchName, All: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), MainBranches: self.c.Model().MainBranches, HashPool: self.c.Model().HashPool, }, ) if err != nil { return err } self.c.Model().Commits = commits self.RefreshAuthors(commits) self.c.Model().WorkingTreeStateAtLastCommitRefresh = self.c.Git().Status.WorkingTreeState() self.c.Model().CheckedOutBranch = checkedOutBranchName self.refreshView(self.c.Contexts().LocalCommits) return nil } func (self *RefreshHelper) refreshSubCommitsWithLimit() error { self.c.Mutexes().SubCommitsMutex.Lock() defer self.c.Mutexes().SubCommitsMutex.Unlock() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: self.c.Contexts().SubCommits.GetLimitCommits(), FilterPath: self.c.Modes().Filtering.GetPath(), FilterAuthor: self.c.Modes().Filtering.GetAuthor(), IncludeRebaseCommits: false, RefName: self.c.Contexts().SubCommits.GetRef().FullRefName(), RefToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(), RefForPushedStatus: self.c.Contexts().SubCommits.GetRef().FullRefName(), MainBranches: self.c.Model().MainBranches, HashPool: self.c.Model().HashPool, }, ) if err != nil { return err } self.c.Model().SubCommits = commits self.RefreshAuthors(commits) self.refreshView(self.c.Contexts().SubCommits) return nil } func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { self.c.Mutexes().AuthorsMutex.Lock() defer self.c.Mutexes().AuthorsMutex.Unlock() authors := self.c.Model().Authors for _, commit := range commits { if _, ok := authors[commit.AuthorEmail]; !ok { authors[commit.AuthorEmail] = &models.Author{ Email: commit.AuthorEmail, Name: commit.AuthorName, } } } } func (self *RefreshHelper) refreshCommitFilesContext() error { from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, to, reverse) if err != nil { return err } self.c.Model().CommitFiles = files self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() self.refreshView(self.c.Contexts().CommitFiles) return nil } func (self *RefreshHelper) refreshRebaseCommits() error { self.c.Mutexes().LocalCommitsMutex.Lock() defer self.c.Mutexes().LocalCommitsMutex.Unlock() updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) if err != nil { return err } self.c.Model().Commits = updatedCommits self.c.Model().WorkingTreeStateAtLastCommitRefresh = self.c.Git().Status.WorkingTreeState() self.refreshView(self.c.Contexts().LocalCommits) return nil } func (self *RefreshHelper) refreshTags() error { tags, err := self.c.Git().Loaders.TagLoader.GetTags() if err != nil { return err } self.c.Model().Tags = tags self.refreshView(self.c.Contexts().Tags) return nil } func (self *RefreshHelper) refreshStateSubmoduleConfigs() error { configs, err := self.c.Git().Submodule.GetConfigs(nil) if err != nil { return err } self.c.Model().Submodules = configs return nil } // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool) { self.c.Mutexes().RefreshingBranchesMutex.Lock() defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() prevSelectedBranch := self.c.Contexts().Branches.GetSelected() reflogCommits := self.c.Model().FilteredReflogCommits if self.c.Modes().Filtering.Active() && self.c.AppState.LocalBranchSortOrder == "recency" { // in filter mode we filter our reflog commits to just those containing the path // however we need all the reflog entries to populate the recencies of our branches // which allows us to order them correctly. So if we're filtering we'll just // manually load all the reflog commits here var err error reflogCommits, _, err = self.c.Git().Loaders.ReflogCommitLoader.GetReflogCommits(self.c.Model().HashPool, nil, "", "") if err != nil { self.c.Log.Error(err) } } branches, err := self.c.Git().Loaders.BranchLoader.Load( reflogCommits, self.c.Model().MainBranches, self.c.Model().Branches, loadBehindCounts, func(f func() error) { self.c.OnWorker(func(_ gocui.Task) error { return f() }) }, func() { self.c.OnUIThread(func() error { self.c.Contexts().Branches.HandleRender() self.refreshStatus() return nil }) }) if err != nil { self.c.Log.Error(err) } self.c.Model().Branches = branches if refreshWorktrees { self.loadWorktrees() self.refreshView(self.c.Contexts().Worktrees) } if !keepBranchSelectionIndex && prevSelectedBranch != nil { self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) if found { self.c.Contexts().Branches.SetSelectedLineIdx(idx) } } self.refreshView(self.c.Contexts().Branches) // Need to re-render the commits view because the visualization of local // branch heads might have changed self.c.Mutexes().LocalCommitsMutex.Lock() self.c.Contexts().LocalCommits.HandleRender() self.c.Mutexes().LocalCommitsMutex.Unlock() self.refreshStatus() } func (self *RefreshHelper) refreshFilesAndSubmodules() error { self.c.Mutexes().RefreshingFilesMutex.Lock() self.c.State().SetIsRefreshingFiles(true) defer func() { self.c.State().SetIsRefreshingFiles(false) self.c.Mutexes().RefreshingFilesMutex.Unlock() }() if err := self.refreshStateSubmoduleConfigs(); err != nil { return err } if err := self.refreshStateFiles(); err != nil { return err } self.c.OnUIThread(func() error { self.refreshView(self.c.Contexts().Submodules) self.refreshView(self.c.Contexts().Files) return nil }) return nil } func (self *RefreshHelper) refreshStateFiles() error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel prevConflictFileCount := 0 if self.c.UserConfig().Git.AutoStageResolvedConflicts { // If git thinks any of our files have inline merge conflicts, but they actually don't, // we stage them. // Note that if files with merge conflicts have both arisen and have been resolved // between refreshes, we won't stage them here. This is super unlikely though, // and this approach spares us from having to call `git status` twice in a row. // Although this also means that at startup we won't be staging anything until // we call git status again. pathsToStage := []string{} for _, file := range self.c.Model().Files { if file.HasMergeConflicts { prevConflictFileCount++ } if file.HasInlineMergeConflicts { hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Path) if err != nil { self.c.Log.Error(err) } else if !hasConflicts { pathsToStage = append(pathsToStage, file.Path) } } } if len(pathsToStage) > 0 { self.c.LogAction(self.c.Tr.Actions.StageResolvedFiles) if err := self.c.Git().WorkingTree.StageFiles(pathsToStage, nil); err != nil { return err } } } files := self.c.Git().Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ ForceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(), }) conflictFileCount := 0 for _, file := range files { if file.HasMergeConflicts { conflictFileCount++ } } if self.c.Git().Status.WorkingTreeState().Any() && conflictFileCount == 0 && prevConflictFileCount > 0 { self.c.OnUIThread(func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } fileTreeViewModel.RWMutex.Lock() // only taking over the filter if it hasn't already been set by the user. if conflictFileCount > 0 && prevConflictFileCount == 0 { if fileTreeViewModel.GetFilter() == filetree.DisplayAll { fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles } } else if conflictFileCount == 0 && fileTreeViewModel.GetFilter() == filetree.DisplayConflicted { fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) self.c.Contexts().Files.GetView().Subtitle = "" } self.c.Model().Files = files fileTreeViewModel.SetTree() fileTreeViewModel.RWMutex.Unlock() return nil } // the reflogs panel is the only panel where we cache data, in that we only // load entries that have been created since we last ran the call. This means // we need to be more careful with how we use this, and to ensure we're emptying // the reflogs array when changing contexts. // This method also manages two things: ReflogCommits and FilteredReflogCommits. // FilteredReflogCommits are rendered in the reflogs panel, and ReflogCommits // are used by the branches panel to obtain recency values for sorting. func (self *RefreshHelper) refreshReflogCommits() error { // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() var lastReflogCommit *models.Commit if len(model.ReflogCommits) > 0 { lastReflogCommit = model.ReflogCommits[0] } refresh := func(stateCommits *[]*models.Commit, filterPath string, filterAuthor string) error { commits, onlyObtainedNewReflogCommits, err := self.c.Git().Loaders.ReflogCommitLoader. GetReflogCommits(self.c.Model().HashPool, lastReflogCommit, filterPath, filterAuthor) if err != nil { return err } if onlyObtainedNewReflogCommits { *stateCommits = append(commits, *stateCommits...) } else { *stateCommits = commits } return nil } if err := refresh(&model.ReflogCommits, "", ""); err != nil { return err } if self.c.Modes().Filtering.Active() { if err := refresh(&model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()); err != nil { return err } } else { model.FilteredReflogCommits = model.ReflogCommits } self.refreshView(self.c.Contexts().ReflogCommits) return nil } func (self *RefreshHelper) refreshRemotes() error { prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() if err != nil { return err } self.c.Model().Remotes = remotes // we need to ensure our selected remote branches aren't now outdated if prevSelectedRemote != nil && self.c.Model().RemoteBranches != nil { // find remote now for _, remote := range remotes { if remote.Name == prevSelectedRemote.Name { self.c.Model().RemoteBranches = remote.Branches break } } } self.refreshView(self.c.Contexts().Remotes) self.refreshView(self.c.Contexts().RemoteBranches) return nil } func (self *RefreshHelper) loadWorktrees() { worktrees, err := self.c.Git().Loaders.Worktrees.GetWorktrees() if err != nil { self.c.Log.Error(err) self.c.Model().Worktrees = []*models.Worktree{} } self.c.Model().Worktrees = worktrees } func (self *RefreshHelper) refreshWorktrees() { self.loadWorktrees() // need to refresh branches because the branches view shows worktrees against // branches self.refreshView(self.c.Contexts().Branches) self.refreshView(self.c.Contexts().Worktrees) } func (self *RefreshHelper) refreshStashEntries() { self.c.Model().StashEntries = self.c.Git().Loaders.StashLoader. GetStashEntries(self.c.Modes().Filtering.GetPath()) self.refreshView(self.c.Contexts().Stash) } // never call this on its own, it should only be called from within refreshCommits() func (self *RefreshHelper) refreshStatus() { self.c.Mutexes().RefreshingStatusMutex.Lock() defer self.c.Mutexes().RefreshingStatusMutex.Unlock() currentBranch := self.refsHelper.GetCheckedOutRef() if currentBranch == nil { // need to wait for branches to refresh return } workingTreeState := self.c.Git().Status.WorkingTreeState() linkedWorktreeName := self.worktreeHelper.GetLinkedWorktreeName() repoName := self.c.Git().RepoPaths.RepoName() status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) self.c.SetViewContent(self.c.Views().Status, status) } func (self *RefreshHelper) refForLog() string { bisectInfo := self.c.Git().Bisect.GetInfo() self.c.Model().BisectInfo = bisectInfo if !bisectInfo.Started() { return "HEAD" } // need to see if our bisect's current commit is reachable from our 'new' ref. if bisectInfo.Bisecting() && !self.c.Git().Bisect.ReachableFromStart(bisectInfo) { return bisectInfo.GetNewHash() } return bisectInfo.GetStartHash() } func (self *RefreshHelper) refreshView(context types.Context) { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) self.c.PostRefreshUpdate(context) self.c.AfterLayout(func() error { // Re-applying the search must be done after re-rendering the view though, // so that the "x of y" status is shown correctly. // // Also, it must be done after layout, because otherwise FocusPoint // hasn't been called yet (see ListContextTrait.FocusLine), which means // that the scroll position might be such that the entire visible // content is outside the viewport. And this would cause problems in // searchModelCommits. self.searchHelper.ReApplySearch(context) return nil }) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/refs_helper.go000066400000000000000000000451111500612110400241340ustar00rootroot00000000000000package helpers import ( "fmt" "strings" "text/template" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type RefsHelper struct { c *HelperCommon rebaseHelper *MergeAndRebaseHelper } func NewRefsHelper( c *HelperCommon, rebaseHelper *MergeAndRebaseHelper, ) *RefsHelper { return &RefsHelper{ c: c, rebaseHelper: rebaseHelper, } } func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions) error { waitingStatus := options.WaitingStatus if waitingStatus == "" { waitingStatus = self.c.Tr.CheckingOutStatus } cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars} refresh := func() { self.c.Contexts().Branches.SetSelection(0) self.c.Contexts().ReflogCommits.SetSelection(0) self.c.Contexts().LocalCommits.SetSelection(0) // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) _ = self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) } localBranch, found := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { return branch.Name == ref }) withCheckoutStatus := func(f func(gocui.Task) error) error { if found { return self.c.WithInlineStatus(localBranch, types.ItemOperationCheckingOut, context.LOCAL_BRANCHES_CONTEXT_KEY, f) } else { return self.c.WithWaitingStatus(waitingStatus, f) } } return withCheckoutStatus(func(gocui.Task) error { if err := self.c.Git().Branch.Checkout(ref, cmdOptions); err != nil { // note, this will only work for english-language git commands. If we force git to use english, and the error isn't this one, then the user will receive an english command they may not understand. I'm not sure what the best solution to this is. Running the command once in english and a second time in the native language is one option if options.OnRefNotFound != nil && strings.Contains(err.Error(), "did not match any file(s) known to git") { return options.OnRefNotFound(ref) } if IsSwitchBranchUncommittedChangesError(err) { // offer to autostash changes self.c.OnUIThread(func() error { // (Before showing the prompt, render again to remove the inline status) self.c.Contexts().Branches.HandleRender() self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.AutoStashTitle, Prompt: self.c.Tr.AutoStashPrompt, HandleConfirm: func() error { return withCheckoutStatus(func(gocui.Task) error { if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + ref); err != nil { return err } if err := self.c.Git().Branch.Checkout(ref, cmdOptions); err != nil { return err } err := self.c.Git().Stash.Pop(0) // Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict). refresh() return err }) }, }) return nil }) return nil } return err } refresh() return nil }) } // Shows a prompt to choose between creating a new branch or checking out a detached head func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchName string) error { checkout := func(branchName string) error { // Switch to the branches context _before_ starting to check out the // branch, so that we see the inline status if self.c.Context().Current() != self.c.Contexts().Branches { self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) } return self.CheckoutRef(branchName, types.CheckoutRefOptions{}) } // If a branch with this name already exists locally, just check it out. We // don't bother checking whether it actually tracks this remote branch, since // it's very unlikely that it doesn't. if lo.ContainsBy(self.c.Model().Branches, func(branch *models.Branch) bool { return branch.Name == localBranchName }) { return checkout(localBranchName) } return self.c.Menu(types.CreateMenuOptions{ Title: utils.ResolvePlaceholderString(self.c.Tr.RemoteBranchCheckoutTitle, map[string]string{ "branchName": fullBranchName, }), Prompt: self.c.Tr.RemoteBranchCheckoutPrompt, Items: []*types.MenuItem{ { Label: self.c.Tr.CheckoutTypeNewBranch, Tooltip: self.c.Tr.CheckoutTypeNewBranchTooltip, OnPress: func() error { // First create the local branch with the upstream set, and // then check it out. We could do that in one step using // "git checkout -b", but we want to benefit from all the // nice features of the CheckoutRef function. if err := self.c.Git().Branch.CreateWithUpstream(localBranchName, fullBranchName); err != nil { return err } // Do a sync refresh to make sure the new branch is visible, // so that we see an inline status when checking it out if err := self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}, }); err != nil { return err } return checkout(localBranchName) }, }, { Label: self.c.Tr.CheckoutTypeDetachedHead, Tooltip: self.c.Tr.CheckoutTypeDetachedHeadTooltip, OnPress: func() error { return checkout(fullBranchName) }, }, }, }) } func (self *RefsHelper) GetCheckedOutRef() *models.Branch { if len(self.c.Model().Branches) == 0 { return nil } return self.c.Model().Branches[0] } func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string) error { if err := self.c.Git().Commit.ResetToCommit(ref, strength, envVars); err != nil { return err } self.c.Contexts().LocalCommits.SetSelection(0) self.c.Contexts().ReflogCommits.SetSelection(0) // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}}); err != nil { return err } return nil } func (self *RefsHelper) CreateSortOrderMenu(sortOptionsOrder []string, onSelected func(sortOrder string) error, currentValue string) error { type sortMenuOption struct { key types.Key label string description string sortOrder string } availableSortOptions := map[string]sortMenuOption{ "recency": {label: self.c.Tr.SortByRecency, description: self.c.Tr.SortBasedOnReflog, key: 'r'}, "alphabetical": {label: self.c.Tr.SortAlphabetical, description: "--sort=refname", key: 'a'}, "date": {label: self.c.Tr.SortByDate, description: "--sort=-committerdate", key: 'd'}, } sortOptions := make([]sortMenuOption, 0, len(sortOptionsOrder)) for _, key := range sortOptionsOrder { sortOption, ok := availableSortOptions[key] if !ok { panic(fmt.Sprintf("unexpected sort order: %s", key)) } sortOption.sortOrder = key sortOptions = append(sortOptions, sortOption) } menuItems := lo.Map(sortOptions, func(opt sortMenuOption, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{ opt.label, style.FgYellow.Sprint(opt.description), }, OnPress: func() error { return onSelected(opt.sortOrder) }, Key: opt.key, Widget: types.MakeMenuRadioButton(opt.sortOrder == currentValue), } }) return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.SortOrder, Items: menuItems, }) } func (self *RefsHelper) CreateGitResetMenu(ref string) error { type strengthWithKey struct { strength string label string key types.Key tooltip string } strengths := []strengthWithKey{ // not i18'ing because it's git terminology {strength: "mixed", label: "Mixed reset", key: 'm', tooltip: self.c.Tr.ResetMixedTooltip}, {strength: "soft", label: "Soft reset", key: 's', tooltip: self.c.Tr.ResetSoftTooltip}, {strength: "hard", label: "Hard reset", key: 'h', tooltip: self.c.Tr.ResetHardTooltip}, } menuItems := lo.Map(strengths, func(row strengthWithKey, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{ row.label, style.FgRed.Sprintf("reset --%s %s", row.strength, ref), }, OnPress: func() error { self.c.LogAction("Reset") return self.ResetToRef(ref, row.strength, []string{}) }, Key: row.key, Tooltip: row.tooltip, } }) return self.c.Menu(types.CreateMenuOptions{ Title: fmt.Sprintf("%s %s", self.c.Tr.ResetTo, ref), Items: menuItems, }) } func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error { branches := lo.Filter(self.c.Model().Branches, func(branch *models.Branch, _ int) bool { return commit.Hash() == branch.CommitHash && branch.Name != self.c.Model().CheckedOutBranch }) hash := commit.Hash() menuItems := []*types.MenuItem{ { LabelColumns: []string{fmt.Sprintf(self.c.Tr.Actions.CheckoutCommitAsDetachedHead, utils.ShortHash(hash))}, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.CheckoutCommit) return self.CheckoutRef(hash, types.CheckoutRefOptions{}) }, Key: 'd', }, } if len(branches) > 0 { menuItems = append(menuItems, lo.Map(branches, func(branch *models.Branch, index int) *types.MenuItem { var key types.Key if index < 9 { key = rune(index + 1 + '0') // Convert 1-based index to key } return &types.MenuItem{ LabelColumns: []string{fmt.Sprintf(self.c.Tr.Actions.CheckoutBranchAtCommit, branch.Name)}, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.CheckoutBranch) return self.CheckoutRef(branch.RefName(), types.CheckoutRefOptions{}) }, Key: key, } })...) } else { menuItems = append(menuItems, &types.MenuItem{ LabelColumns: []string{self.c.Tr.Actions.CheckoutBranch}, OnPress: func() error { return nil }, DisabledReason: &types.DisabledReason{Text: self.c.Tr.NoBranchesFoundAtCommitTooltip}, Key: '1', }) } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Actions.CheckoutBranchOrCommit, Items: menuItems, }) } func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggestedBranchName string) error { message := utils.ResolvePlaceholderString( self.c.Tr.NewBranchNameBranchOff, map[string]string{ "branchName": fromFormattedName, }, ) if suggestedBranchName == "" { var err error suggestedBranchName, err = utils.ResolveTemplate(self.c.UserConfig().Git.BranchPrefix, nil, template.FuncMap{ "runCommand": self.c.Git().Custom.TemplateFunctionRunCommand, }) if err != nil { return err } suggestedBranchName = strings.ReplaceAll(suggestedBranchName, "\t", " ") } refresh := func() error { if self.c.Context().Current() != self.c.Contexts().Branches { self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) } self.c.Contexts().LocalCommits.SetSelection(0) self.c.Contexts().Branches.SetSelection(0) return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) } self.c.Prompt(types.PromptOpts{ Title: message, InitialContent: suggestedBranchName, HandleConfirm: func(response string) error { self.c.LogAction(self.c.Tr.Actions.CreateBranch) newBranchName := SanitizedBranchName(response) newBranchFunc := self.c.Git().Branch.New if newBranchName != suggestedBranchName { newBranchFunc = self.c.Git().Branch.NewWithoutTracking } if err := newBranchFunc(newBranchName, from); err != nil { if IsSwitchBranchUncommittedChangesError(err) { // offer to autostash changes self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.AutoStashTitle, Prompt: self.c.Tr.AutoStashPrompt, HandleConfirm: func() error { if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + newBranchName); err != nil { return err } if err := newBranchFunc(newBranchName, from); err != nil { return err } popErr := self.c.Git().Stash.Pop(0) // Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict). refreshError := refresh() if popErr != nil { // An error from pop is the more important one to report to the user return popErr } return refreshError }, }) return nil } return err } return refresh() }, }) return nil } func (self *RefsHelper) MoveCommitsToNewBranch() error { currentBranch := self.c.Model().Branches[0] baseBranchRef, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(currentBranch, self.c.Model().MainBranches) if err != nil { return err } withNewBranchNamePrompt := func(baseBranchName string, f func(string, string) error) { prompt := utils.ResolvePlaceholderString( self.c.Tr.NewBranchNameBranchOff, map[string]string{ "branchName": baseBranchName, }, ) self.c.Prompt(types.PromptOpts{ Title: prompt, HandleConfirm: func(response string) error { self.c.LogAction(self.c.Tr.MoveCommitsToNewBranch) newBranchName := SanitizedBranchName(response) return self.c.WithWaitingStatus(self.c.Tr.MovingCommitsToNewBranchStatus, func(gocui.Task) error { return f(currentBranch.Name, newBranchName) }) }, }) } isMainBranch := lo.Contains(self.c.UserConfig().Git.MainBranches, currentBranch.Name) if isMainBranch { prompt := utils.ResolvePlaceholderString( self.c.Tr.MoveCommitsToNewBranchFromMainPrompt, map[string]string{ "baseBranchName": currentBranch.Name, }, ) self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.MoveCommitsToNewBranch, Prompt: prompt, HandleConfirm: func() error { withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch) return nil }, }) return nil } shortBaseBranchName := ShortBranchName(baseBranchRef) prompt := utils.ResolvePlaceholderString( self.c.Tr.MoveCommitsToNewBranchMenuPrompt, map[string]string{ "baseBranchName": shortBaseBranchName, }, ) return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.MoveCommitsToNewBranch, Prompt: prompt, Items: []*types.MenuItem{ { Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName), OnPress: func() error { withNewBranchNamePrompt(shortBaseBranchName, func(currentBranch string, newBranchName string) error { return self.moveCommitsToNewBranchOffOfMainBranch(currentBranch, newBranchName, baseBranchRef) }) return nil }, }, { Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchStackedItem, currentBranch.Name), OnPress: func() error { withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch) return nil }, }, }, }) } func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(currentBranch string, newBranchName string) error { if err := self.c.Git().Branch.NewWithoutCheckout(newBranchName, "HEAD"); err != nil { return err } mustStash := IsWorkingTreeDirty(self.c.Model().Files) if mustStash { if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + currentBranch); err != nil { return err } } if err := self.c.Git().Commit.ResetToCommit("@{u}", "hard", []string{}); err != nil { return err } if err := self.c.Git().Branch.Checkout(newBranchName, git_commands.CheckoutOptions{}); err != nil { return err } if mustStash { if err := self.c.Git().Stash.Pop(0); err != nil { return err } } self.c.Contexts().LocalCommits.SetSelection(0) self.c.Contexts().Branches.SetSelection(0) return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) } func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(currentBranch string, newBranchName string, baseBranchRef string) error { commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool { return commit.Status == models.StatusUnpushed }) mustStash := IsWorkingTreeDirty(self.c.Model().Files) if mustStash { if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + currentBranch); err != nil { return err } } if err := self.c.Git().Commit.ResetToCommit("@{u}", "hard", []string{}); err != nil { return err } if err := self.c.Git().Branch.NewWithoutTracking(newBranchName, baseBranchRef); err != nil { return err } err := self.c.Git().Rebase.CherryPickCommits(commitsToCherryPick) err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{Mode: types.SYNC}) if err != nil { return err } if mustStash { if err := self.c.Git().Stash.Pop(0); err != nil { return err } } self.c.Contexts().LocalCommits.SetSelection(0) self.c.Contexts().Branches.SetSelection(0) return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) } func (self *RefsHelper) CanMoveCommitsToNewBranch() *types.DisabledReason { if len(self.c.Model().Branches) == 0 { return &types.DisabledReason{Text: self.c.Tr.NoBranchesThisRepo} } currentBranch := self.GetCheckedOutRef() if currentBranch.DetachedHead { return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsFromDetachedHead, ShowErrorInPanel: true} } if !currentBranch.RemoteBranchStoredLocally() { return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsNoUpstream, ShowErrorInPanel: true} } if currentBranch.IsBehindForPull() { return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsBehindUpstream, ShowErrorInPanel: true} } if !currentBranch.IsAheadForPull() { return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsNoUnpushedCommits, ShowErrorInPanel: true} } return nil } // SanitizedBranchName will remove all spaces in favor of a dash "-" to meet // git's branch naming requirement. func SanitizedBranchName(input string) string { return strings.Replace(input, " ", "-", -1) } // Checks if the given branch name is a remote branch, and returns the name of // the remote and the bare branch name if it is. func (self *RefsHelper) ParseRemoteBranchName(fullBranchName string) (string, string, bool) { remoteName, branchName, found := strings.Cut(fullBranchName, "/") if !found { return "", "", false } // See if the part before the first slash is actually one of our remotes if !lo.ContainsBy(self.c.Model().Remotes, func(remote *models.Remote) bool { return remote.Name == remoteName }) { return "", "", false } return remoteName, branchName, true } func IsSwitchBranchUncommittedChangesError(err error) bool { return strings.Contains(err.Error(), "Please commit your changes or stash them before you switch branch") } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/repos_helper.go000066400000000000000000000117471500612110400243350ustar00rootroot00000000000000package helpers import ( "errors" "fmt" "os" "path/filepath" "strings" "sync" "github.com/jesseduffield/gocui" appTypes "github.com/jesseduffield/lazygit/pkg/app/types" "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type onNewRepoFn func(startArgs appTypes.StartArgs, contextKey types.ContextKey) error // helps switch back and forth between repos type ReposHelper struct { c *HelperCommon recordDirectoryHelper *RecordDirectoryHelper onNewRepo onNewRepoFn } func NewRecentReposHelper( c *HelperCommon, recordDirectoryHelper *RecordDirectoryHelper, onNewRepo onNewRepoFn, ) *ReposHelper { return &ReposHelper{ c: c, recordDirectoryHelper: recordDirectoryHelper, onNewRepo: onNewRepo, } } func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error { wd, err := os.Getwd() if err != nil { return err } self.c.State().GetRepoPathStack().Push(wd) return self.DispatchSwitchToRepo(submodule.FullPath(), context.NO_CONTEXT) } func (self *ReposHelper) getCurrentBranch(path string) string { readHeadFile := func(path string) (string, error) { headFile, err := os.ReadFile(filepath.Join(path, "HEAD")) if err == nil { content := strings.TrimSpace(string(headFile)) refsPrefix := "ref: refs/heads/" var branchDisplay string if strings.HasPrefix(content, refsPrefix) { // is a branch branchDisplay = strings.TrimPrefix(content, refsPrefix) } else { // detached HEAD state, displaying short hash branchDisplay = utils.ShortHash(content) } return branchDisplay, nil } return "", err } gitDirPath := filepath.Join(path, ".git") if gitDir, err := os.Stat(gitDirPath); err == nil { if gitDir.IsDir() { // ordinary repo if branch, err := readHeadFile(gitDirPath); err == nil { return branch } } else { // worktree if worktreeGitDir, err := os.ReadFile(gitDirPath); err == nil { content := strings.TrimSpace(string(worktreeGitDir)) worktreePath := strings.TrimPrefix(content, "gitdir: ") if branch, err := readHeadFile(worktreePath); err == nil { return branch } } } } return self.c.Tr.BranchUnknown } func (self *ReposHelper) CreateRecentReposMenu() error { // we'll show an empty panel if there are no recent repos recentRepoPaths := []string{} if len(self.c.GetAppState().RecentRepos) > 0 { // we skip the first one because we're currently in it recentRepoPaths = self.c.GetAppState().RecentRepos[1:] } currentBranches := sync.Map{} wg := sync.WaitGroup{} wg.Add(len(recentRepoPaths)) for _, path := range recentRepoPaths { go func(path string) { defer wg.Done() currentBranches.Store(path, self.getCurrentBranch(path)) }(path) } wg.Wait() menuItems := lo.Map(recentRepoPaths, func(path string, _ int) *types.MenuItem { branchName, _ := currentBranches.Load(path) if icons.IsIconEnabled() { branchName = icons.BRANCH_ICON + " " + fmt.Sprintf("%v", branchName) } return &types.MenuItem{ LabelColumns: []string{ filepath.Base(path), style.FgCyan.Sprint(branchName), style.FgMagenta.Sprint(path), }, OnPress: func() error { // if we were in a submodule, we want to forget about that stack of repos // so that hitting escape in the new repo does nothing self.c.State().GetRepoPathStack().Clear() return self.DispatchSwitchToRepo(path, context.NO_CONTEXT) }, } }) return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems}) } func (self *ReposHelper) DispatchSwitchToRepo(path string, contextKey types.ContextKey) error { return self.DispatchSwitchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey) } func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error { return self.c.WithWaitingStatus(self.c.Tr.Switching, func(gocui.Task) error { env.UnsetGitLocationEnvVars() originalPath, err := os.Getwd() if err != nil { return nil } msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path}) self.c.LogCommand(msg, false) if err := os.Chdir(path); err != nil { if os.IsNotExist(err) { return errors.New(errMsg) } return err } if err := commands.VerifyInGitRepo(self.c.OS()); err != nil { if err := os.Chdir(originalPath); err != nil { return err } return err } if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil { return err } self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() return self.onNewRepo(appTypes.StartArgs{}, contextKey) }) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/search_helper.go000066400000000000000000000212721500612110400244440ustar00rootroot00000000000000package helpers import ( "fmt" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" ) // NOTE: this helper supports both filtering and searching. Filtering is when // the contents of the list are filtered, whereas searching does not actually // change the contents of the list but instead just highlights the search. // The general term we use to capture both searching and filtering is... // 'searching', which is unfortunate but I can't think of a better name. type SearchHelper struct { c *HelperCommon } func NewSearchHelper( c *HelperCommon, ) *SearchHelper { return &SearchHelper{ c: c, } } func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error { state := self.searchState() state.Context = context self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) promptView := self.promptView() promptView.ClearTextArea() self.OnPromptContentChanged("") promptView.RenderTextArea() self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{}) return self.c.ResetKeybindings() } func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error { state := self.searchState() state.PrevSearchIndex = -1 state.Context = context self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix) promptView := self.promptView() promptView.ClearTextArea() promptView.RenderTextArea() self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{}) return self.c.ResetKeybindings() } func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) { state := self.searchState() state.Context = context searchString := context.GetFilter() self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) promptView := self.promptView() keybindingConfig := self.c.UserConfig().Keybinding promptView.SetContent(fmt.Sprintf("matches for '%s' ", searchString) + theme.OptionsFgColor.Sprintf(self.c.Tr.ExitTextFilterMode, keybindings.Label(keybindingConfig.Universal.Return))) } func (self *SearchHelper) DisplaySearchStatus(context types.ISearchableContext) { state := self.searchState() state.Context = context self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix) index, totalCount := context.GetView().GetSearchStatus() context.RenderSearchStatus(index, totalCount) } func (self *SearchHelper) searchState() *types.SearchState { return self.c.State().GetRepoState().GetSearchState() } func (self *SearchHelper) searchPrefixView() *gocui.View { return self.c.Views().SearchPrefix } func (self *SearchHelper) promptView() *gocui.View { return self.c.Contexts().Search.GetView() } func (self *SearchHelper) promptContent() string { return self.c.Contexts().Search.GetView().TextArea.GetContent() } func (self *SearchHelper) Confirm() error { state := self.searchState() if self.promptContent() == "" { return self.CancelPrompt() } var err error switch state.SearchType() { case types.SearchTypeFilter: self.ConfirmFilter() case types.SearchTypeSearch: err = self.ConfirmSearch() case types.SearchTypeNone: self.c.Context().Pop() } if err != nil { return err } return self.c.ResetKeybindings() } func (self *SearchHelper) ConfirmFilter() { // We also do this on each keypress but we do it here again just in case state := self.searchState() context, ok := state.Context.(types.IFilterableContext) if !ok { self.c.Log.Warnf("Context %s is not filterable", state.Context.GetKey()) return } self.OnPromptContentChanged(self.promptContent()) filterString := self.promptContent() if filterString != "" { context.GetSearchHistory().Push(filterString) } self.c.Context().Pop() } func (self *SearchHelper) ConfirmSearch() error { state := self.searchState() context, ok := state.Context.(types.ISearchableContext) if !ok { self.c.Log.Warnf("Context %s is searchable", state.Context.GetKey()) return nil } searchString := self.promptContent() context.SetSearchString(searchString) if searchString != "" { context.GetSearchHistory().Push(searchString) } self.c.Context().Pop() return context.GetView().Search(searchString, modelSearchResults(context)) } func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition { searchString := context.GetSearchString() var normalizedSearchStr string // if we have any uppercase characters we'll do a case-sensitive search caseSensitive := utils.ContainsUppercase(searchString) if caseSensitive { normalizedSearchStr = searchString } else { normalizedSearchStr = strings.ToLower(searchString) } return context.ModelSearchResults(normalizedSearchStr, caseSensitive) } func (self *SearchHelper) CancelPrompt() error { self.Cancel() self.c.Context().Pop() return self.c.ResetKeybindings() } func (self *SearchHelper) ScrollHistory(scrollIncrement int) { state := self.searchState() context, ok := state.Context.(types.ISearchHistoryContext) if !ok { return } states := context.GetSearchHistory() if val, err := states.PeekAt(state.PrevSearchIndex + scrollIncrement); err == nil { state.PrevSearchIndex += scrollIncrement promptView := self.promptView() promptView.ClearTextArea() promptView.TextArea.TypeString(val) promptView.RenderTextArea() self.OnPromptContentChanged(val) } } func (self *SearchHelper) Cancel() { state := self.searchState() switch context := state.Context.(type) { case types.IFilterableContext: context.ClearFilter() self.c.PostRefreshUpdate(context) case types.ISearchableContext: context.ClearSearchString() context.GetView().ClearSearch() default: // do nothing } self.HidePrompt() } func (self *SearchHelper) OnPromptContentChanged(searchString string) { state := self.searchState() switch context := state.Context.(type) { case types.IFilterableContext: context.SetSelection(0) context.GetView().SetOriginY(0) context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch()) self.c.PostRefreshUpdate(context) case types.ISearchableContext: // do nothing default: // do nothing (shouldn't land here) } } func (self *SearchHelper) ReApplyFilter(context types.Context) { filterableContext, ok := context.(types.IFilterableContext) if ok { state := self.searchState() if context == state.Context { filterableContext.SetSelection(0) filterableContext.GetView().SetOriginY(0) } filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch()) } } func (self *SearchHelper) ReApplySearch(ctx types.Context) { // Reapply the search if the model has changed. This is needed for contexts // that use the model for searching, to pass the new model search positions // to the view. searchableContext, ok := ctx.(types.ISearchableContext) if ok { ctx.GetView().UpdateSearchResults(searchableContext.GetSearchString(), modelSearchResults(searchableContext)) state := self.searchState() if ctx == state.Context { // Re-render the "x of y" search status, unless the search prompt is // open for typing. if self.c.Context().Current().GetKey() != context.SEARCH_CONTEXT_KEY { self.RenderSearchStatus(searchableContext) } } } } func (self *SearchHelper) RenderSearchStatus(c types.Context) { if c.GetKey() == context.SEARCH_CONTEXT_KEY { return } if searchableContext, ok := c.(types.ISearchableContext); ok { if searchableContext.IsSearching() { self.setSearchingFrameColor() self.DisplaySearchStatus(searchableContext) return } } if filterableContext, ok := c.(types.IFilterableContext); ok { if filterableContext.IsFiltering() { self.setSearchingFrameColor() self.DisplayFilterStatus(filterableContext) return } } self.HidePrompt() } func (self *SearchHelper) CancelSearchIfSearching(c types.Context) { if searchableContext, ok := c.(types.ISearchableContext); ok { view := searchableContext.GetView() if view != nil && view.IsSearching() { view.ClearSearch() searchableContext.ClearSearchString() self.Cancel() } return } if filterableContext, ok := c.(types.IFilterableContext); ok { if filterableContext.IsFiltering() { filterableContext.ClearFilter() self.Cancel() } return } } func (self *SearchHelper) HidePrompt() { self.setNonSearchingFrameColor() state := self.searchState() state.Context = nil } func (self *SearchHelper) setSearchingFrameColor() { self.c.GocuiGui().SelFgColor = theme.SearchingActiveBorderColor self.c.GocuiGui().SelFrameColor = theme.SearchingActiveBorderColor } func (self *SearchHelper) setNonSearchingFrameColor() { self.c.GocuiGui().SelFgColor = theme.ActiveBorderColor self.c.GocuiGui().SelFrameColor = theme.ActiveBorderColor } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/snake_helper.go000066400000000000000000000026601500612110400243000ustar00rootroot00000000000000package helpers import ( "errors" "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/snake" ) type SnakeHelper struct { c *HelperCommon game *snake.Game } func NewSnakeHelper(c *HelperCommon) *SnakeHelper { return &SnakeHelper{ c: c, } } func (self *SnakeHelper) StartGame() { view := self.c.Views().Snake game := snake.NewGame(view.InnerWidth(), view.InnerHeight(), self.renderSnakeGame, self.c.LogAction) self.game = game game.Start() } func (self *SnakeHelper) ExitGame() { self.game.Exit() } func (self *SnakeHelper) SetDirection(direction snake.Direction) { self.game.SetDirection(direction) } func (self *SnakeHelper) renderSnakeGame(cells [][]snake.CellType, alive bool) { view := self.c.Views().Snake if !alive { self.c.OnUIThread(func() error { return errors.New(self.c.Tr.YouDied) }) return } output := self.drawSnakeGame(cells) view.Clear() fmt.Fprint(view, output) self.c.Render() } func (self *SnakeHelper) drawSnakeGame(cells [][]snake.CellType) string { writer := &strings.Builder{} for i, row := range cells { for _, cell := range row { switch cell { case snake.None: writer.WriteString(" ") case snake.Snake: writer.WriteString("█") case snake.Food: writer.WriteString(style.FgMagenta.Sprint("█")) } } if i < len(cells) { writer.WriteString("\n") } } output := writer.String() return output } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/staging_helper.go000066400000000000000000000067111500612110400246340ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type StagingHelper struct { c *HelperCommon } func NewStagingHelper( c *HelperCommon, ) *StagingHelper { return &StagingHelper{ c: c, } } // NOTE: used from outside this file func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) { secondaryFocused := self.secondaryStagingFocused() mainFocused := self.mainStagingFocused() // this method could be called when the staging panel is not being used, // in which case we don't want to do anything. if !mainFocused && !secondaryFocused { return } mainSelectedLineIdx := -1 secondarySelectedLineIdx := -1 if focusOpts.ClickedViewLineIdx > 0 { if secondaryFocused { secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx } else { mainSelectedLineIdx = focusOpts.ClickedViewLineIdx } } mainContext := self.c.Contexts().Staging secondaryContext := self.c.Contexts().StagingSecondary var file *models.File node := self.c.Contexts().Files.GetSelected() if node != nil { file = node.File } if file == nil || (!file.HasUnstagedChanges && !file.HasStagedChanges) { self.handleStagingEscape() return } mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, false) secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, true) // grabbing locks here and releasing before we finish the function // because pushing say the secondary context could mean entering this function // again, and we don't want to have a deadlock mainContext.GetMutex().Lock() secondaryContext.GetMutex().Lock() mainContext.SetState( patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetView(), mainContext.GetState()), ) secondaryContext.SetState( patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetView(), secondaryContext.GetState()), ) mainState := mainContext.GetState() secondaryState := secondaryContext.GetState() mainContent := mainContext.GetContentToRender() secondaryContent := secondaryContext.GetContentToRender() mainContext.GetMutex().Unlock() secondaryContext.GetMutex().Unlock() if mainState == nil && secondaryState == nil { self.handleStagingEscape() return } if mainState == nil && !secondaryFocused { self.c.Context().Push(secondaryContext, focusOpts) return } if secondaryState == nil && secondaryFocused { self.c.Context().Push(mainContext, focusOpts) return } if secondaryFocused { self.c.Contexts().StagingSecondary.FocusSelection() } else { self.c.Contexts().Staging.FocusSelection() } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Staging, Main: &types.ViewUpdateOpts{ Task: types.NewRenderStringWithoutScrollTask(mainContent), Title: self.c.Tr.UnstagedChanges, }, Secondary: &types.ViewUpdateOpts{ Task: types.NewRenderStringWithoutScrollTask(secondaryContent), Title: self.c.Tr.StagedChanges, }, }) } func (self *StagingHelper) handleStagingEscape() { self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } func (self *StagingHelper) secondaryStagingFocused() bool { return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().StagingSecondary.GetKey() } func (self *StagingHelper) mainStagingFocused() bool { return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey() } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/sub_commits_helper.go000066400000000000000000000045121500612110400255210ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type SubCommitsHelper struct { c *HelperCommon refreshHelper *RefreshHelper setSubCommits func([]*models.Commit) } func NewSubCommitsHelper( c *HelperCommon, refreshHelper *RefreshHelper, setSubCommits func([]*models.Commit), ) *SubCommitsHelper { return &SubCommitsHelper{ c: c, refreshHelper: refreshHelper, setSubCommits: setSubCommits, } } type ViewSubCommitsOpts struct { Ref types.Ref RefToShowDivergenceFrom string TitleRef string Context types.Context ShowBranchHeads bool } func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error { commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: true, FilterPath: self.c.Modes().Filtering.GetPath(), FilterAuthor: self.c.Modes().Filtering.GetAuthor(), IncludeRebaseCommits: false, RefName: opts.Ref.FullRefName(), RefForPushedStatus: opts.Ref.FullRefName(), RefToShowDivergenceFrom: opts.RefToShowDivergenceFrom, MainBranches: self.c.Model().MainBranches, HashPool: self.c.Model().HashPool, }, ) if err != nil { return err } self.setSubCommits(commits) self.refreshHelper.RefreshAuthors(commits) subCommitsContext := self.c.Contexts().SubCommits subCommitsContext.SetSelection(0) subCommitsContext.SetParentContext(opts.Context) subCommitsContext.SetWindowName(opts.Context.GetWindowName()) subCommitsContext.SetTitleRef(utils.TruncateWithEllipsis(opts.TitleRef, 50)) subCommitsContext.SetRef(opts.Ref) subCommitsContext.SetRefToShowDivergenceFrom(opts.RefToShowDivergenceFrom) subCommitsContext.SetLimitCommits(true) subCommitsContext.SetShowBranchHeads(opts.ShowBranchHeads) subCommitsContext.ClearSearchString() subCommitsContext.GetView().ClearSearch() subCommitsContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix self.c.PostRefreshUpdate(self.c.Contexts().SubCommits) self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{}) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/suggestions_helper.go000066400000000000000000000163031500612110400255500ustar00rootroot00000000000000package helpers import ( "fmt" "os" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/minimal/gitignore" "github.com/samber/lo" "golang.org/x/exp/slices" "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) // Thinking out loud: I'm typically a staunch advocate of organising code by feature rather than type, // because colocating code that relates to the same feature means far less effort // to get all the context you need to work on any particular feature. But the one // major benefit of grouping by type is that it makes it makes it less likely that // somebody will re-implement the same logic twice, because they can quickly see // if a certain method has been used for some use case, given that as a starting point // they know about the type. In that vein, I'm including all our functions for // finding suggestions in this file, so that it's easy to see if a function already // exists for fetching a particular model. type SuggestionsHelper struct { c *HelperCommon } func NewSuggestionsHelper( c *HelperCommon, ) *SuggestionsHelper { return &SuggestionsHelper{ c: c, } } func (self *SuggestionsHelper) getRemoteNames() []string { return lo.Map(self.c.Model().Remotes, func(remote *models.Remote, _ int) string { return remote.Name }) } func matchesToSuggestions(matches []string) []*types.Suggestion { return lo.Map(matches, func(match string, _ int) *types.Suggestion { return &types.Suggestion{ Value: match, Label: match, } }) } func (self *SuggestionsHelper) GetRemoteSuggestionsFunc() func(string) []*types.Suggestion { remoteNames := self.getRemoteNames() return FilterFunc(remoteNames, self.c.UserConfig().Gui.UseFuzzySearch()) } func (self *SuggestionsHelper) getBranchNames() []string { return lo.Map(self.c.Model().Branches, func(branch *models.Branch, _ int) string { return branch.Name }) } func (self *SuggestionsHelper) GetBranchNameSuggestionsFunc() func(string) []*types.Suggestion { branchNames := self.getBranchNames() return func(input string) []*types.Suggestion { var matchingBranchNames []string if input == "" { matchingBranchNames = branchNames } else { matchingBranchNames = utils.FilterStrings(input, branchNames, self.c.UserConfig().Gui.UseFuzzySearch()) } return lo.Map(matchingBranchNames, func(branchName string, _ int) *types.Suggestion { return &types.Suggestion{ Value: branchName, Label: presentation.GetBranchTextStyle(branchName).Sprint(branchName), } }) } } // here we asynchronously fetch the latest set of paths in the repo and store in // self.c.Model().FilesTrie. On the main thread we'll be doing a fuzzy search via // self.c.Model().FilesTrie. So if we've looked for a file previously, we'll start with // the old trie and eventually it'll be swapped out for the new one. // Notably, unlike other suggestion functions we're not showing all the options // if nothing has been typed because there'll be too much to display efficiently func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*types.Suggestion { _ = self.c.WithWaitingStatus(self.c.Tr.LoadingFileSuggestions, func(gocui.Task) error { trie := patricia.NewTrie() // load every non-gitignored file in the repo ignore, err := gitignore.FromGit() if err != nil { return err } err = ignore.Walk(".", func(path string, info os.FileInfo, err error) error { if err != nil { return err } trie.Insert(patricia.Prefix(path), path) return nil }) // cache the trie for future use self.c.Model().FilesTrie = trie self.c.Contexts().Suggestions.RefreshSuggestions() return err }) return func(input string) []*types.Suggestion { matchingNames := []string{} if self.c.UserConfig().Gui.UseFuzzySearch() { _ = self.c.Model().FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error { matchingNames = append(matchingNames, item.(string)) return nil }) // doing another fuzzy search for good measure matchingNames = utils.FilterStrings(input, matchingNames, true) } else { substrings := strings.Fields(input) _ = self.c.Model().FilesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error { for _, sub := range substrings { if !utils.CaseAwareContains(item.(string), sub) { return nil } } matchingNames = append(matchingNames, item.(string)) return nil }) } return matchesToSuggestions(matchingNames) } } func (self *SuggestionsHelper) getRemoteBranchNames(separator string) []string { return lo.FlatMap(self.c.Model().Remotes, func(remote *models.Remote, _ int) []string { return lo.Map(remote.Branches, func(branch *models.RemoteBranch, _ int) string { return fmt.Sprintf("%s%s%s", remote.Name, separator, branch.Name) }) }) } func (self *SuggestionsHelper) getRemoteBranchNamesForRemote(remoteName string) []string { remote, ok := lo.Find(self.c.Model().Remotes, func(remote *models.Remote) bool { return remote.Name == remoteName }) if ok { return lo.Map(remote.Branches, func(branch *models.RemoteBranch, _ int) string { return branch.Name }) } return nil } func (self *SuggestionsHelper) GetRemoteBranchesSuggestionsFunc(separator string) func(string) []*types.Suggestion { return FilterFunc(self.getRemoteBranchNames(separator), self.c.UserConfig().Gui.UseFuzzySearch()) } func (self *SuggestionsHelper) GetRemoteBranchesForRemoteSuggestionsFunc(remoteName string) func(string) []*types.Suggestion { return FilterFunc(self.getRemoteBranchNamesForRemote(remoteName), self.c.UserConfig().Gui.UseFuzzySearch()) } func (self *SuggestionsHelper) getTagNames() []string { return lo.Map(self.c.Model().Tags, func(tag *models.Tag, _ int) string { return tag.Name }) } func (self *SuggestionsHelper) GetTagsSuggestionsFunc() func(string) []*types.Suggestion { tagNames := self.getTagNames() return FilterFunc(tagNames, self.c.UserConfig().Gui.UseFuzzySearch()) } func (self *SuggestionsHelper) GetRefsSuggestionsFunc() func(string) []*types.Suggestion { remoteBranchNames := self.getRemoteBranchNames("/") localBranchNames := self.getBranchNames() tagNames := self.getTagNames() additionalRefNames := []string{"HEAD", "FETCH_HEAD", "MERGE_HEAD", "ORIG_HEAD"} refNames := append(append(append(remoteBranchNames, localBranchNames...), tagNames...), additionalRefNames...) return FilterFunc(refNames, self.c.UserConfig().Gui.UseFuzzySearch()) } func (self *SuggestionsHelper) GetAuthorsSuggestionsFunc() func(string) []*types.Suggestion { authors := lo.Map(lo.Values(self.c.Model().Authors), func(author *models.Author, _ int) string { return author.Combined() }) slices.Sort(authors) return FilterFunc(authors, self.c.UserConfig().Gui.UseFuzzySearch()) } func FilterFunc(options []string, useFuzzySearch bool) func(string) []*types.Suggestion { return func(input string) []*types.Suggestion { var matches []string if input == "" { matches = options } else { matches = utils.FilterStrings(input, options, useFuzzySearch) } return matchesToSuggestions(matches) } } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/tags_helper.go000066400000000000000000000044361500612110400241400ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type TagsHelper struct { c *HelperCommon commitsHelper *CommitsHelper gpg *GpgHelper } func NewTagsHelper(c *HelperCommon, commitsHelper *CommitsHelper, gpg *GpgHelper) *TagsHelper { return &TagsHelper{ c: c, commitsHelper: commitsHelper, gpg: gpg, } } func (self *TagsHelper) OpenCreateTagPrompt(ref string, onCreate func()) error { doCreateTag := func(tagName string, description string, force bool) error { var command oscommands.ICmdObj if description != "" || self.c.Git().Config.GetGpgTagSign() { self.c.LogAction(self.c.Tr.Actions.CreateAnnotatedTag) command = self.c.Git().Tag.CreateAnnotatedObj(tagName, ref, description, force) } else { self.c.LogAction(self.c.Tr.Actions.CreateLightweightTag) command = self.c.Git().Tag.CreateLightweightObj(tagName, ref, force) } return self.gpg.WithGpgHandling(command, git_commands.TagGpgSign, self.c.Tr.CreatingTag, func() error { self.commitsHelper.OnCommitSuccess() return nil }, []types.RefreshableView{types.COMMITS, types.TAGS}) } onConfirm := func(tagName string, description string) error { if self.c.Git().Tag.HasTag(tagName) { prompt := utils.ResolvePlaceholderString( self.c.Tr.ForceTagPrompt, map[string]string{ "tagName": tagName, "cancelKey": self.c.UserConfig().Keybinding.Universal.Return, "confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm, }, ) self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.ForceTag, Prompt: prompt, HandleConfirm: func() error { return doCreateTag(tagName, description, true) }, }) return nil } return doCreateTag(tagName, description, false) } self.commitsHelper.OpenCommitMessagePanel( &OpenCommitMessagePanelOpts{ CommitIndex: context.NoCommitIndex, InitialMessage: "", SummaryTitle: self.c.Tr.TagNameTitle, DescriptionTitle: self.c.Tr.TagMessageTitle, PreserveMessage: false, OnConfirm: onConfirm, }, ) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/update_helper.go000066400000000000000000000047211500612110400244610ustar00rootroot00000000000000package helpers import ( "errors" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/updates" "github.com/jesseduffield/lazygit/pkg/utils" ) type UpdateHelper struct { c *HelperCommon updater *updates.Updater } func NewUpdateHelper(c *HelperCommon, updater *updates.Updater) *UpdateHelper { return &UpdateHelper{ c: c, updater: updater, } } func (self *UpdateHelper) CheckForUpdateInBackground() { self.updater.CheckForNewUpdate(func(newVersion string, err error) error { if err != nil { // ignoring the error for now so that I'm not annoying users self.c.Log.Error(err.Error()) return nil } if newVersion == "" { return nil } if self.c.UserConfig().Update.Method == "background" { self.startUpdating(newVersion) return nil } return self.showUpdatePrompt(newVersion) }, false) } func (self *UpdateHelper) CheckForUpdateInForeground() error { return self.c.WithWaitingStatus(self.c.Tr.CheckingForUpdates, func(gocui.Task) error { self.updater.CheckForNewUpdate(func(newVersion string, err error) error { if err != nil { return err } if newVersion == "" { return errors.New(self.c.Tr.FailedToRetrieveLatestVersionErr) } return self.showUpdatePrompt(newVersion) }, true) return nil }) } func (self *UpdateHelper) startUpdating(newVersion string) { _ = self.c.WithWaitingStatus(self.c.Tr.UpdateInProgressWaitingStatus, func(gocui.Task) error { self.c.State().SetUpdating(true) err := self.updater.Update(newVersion) return self.onUpdateFinish(err) }) } func (self *UpdateHelper) onUpdateFinish(err error) error { self.c.State().SetUpdating(false) self.c.OnUIThread(func() error { self.c.SetViewContent(self.c.Views().AppStatus, "") if err != nil { errMessage := utils.ResolvePlaceholderString( self.c.Tr.UpdateFailedErr, map[string]string{ "errMessage": err.Error(), }, ) return errors.New(errMessage) } self.c.Alert(self.c.Tr.UpdateCompletedTitle, self.c.Tr.UpdateCompleted) return nil }) return nil } func (self *UpdateHelper) showUpdatePrompt(newVersion string) error { message := utils.ResolvePlaceholderString( self.c.Tr.UpdateAvailable, map[string]string{ "newVersion": newVersion, }, ) self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.UpdateAvailableTitle, Prompt: message, HandleConfirm: func() error { self.startUpdating(newVersion) return nil }, }) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/upstream_helper.go000066400000000000000000000040401500612110400250310ustar00rootroot00000000000000package helpers import ( "errors" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type UpstreamHelper struct { c *HelperCommon getRemoteBranchesSuggestionsFunc func(string) func(string) []*types.Suggestion } func NewUpstreamHelper( c *HelperCommon, getRemoteBranchesSuggestionsFunc func(string) func(string) []*types.Suggestion, ) *UpstreamHelper { return &UpstreamHelper{ c: c, getRemoteBranchesSuggestionsFunc: getRemoteBranchesSuggestionsFunc, } } func (self *UpstreamHelper) ParseUpstream(upstream string) (string, string, error) { var upstreamBranch, upstreamRemote string split := strings.Split(upstream, " ") if len(split) != 2 { return "", "", errors.New(self.c.Tr.InvalidUpstream) } upstreamRemote = split[0] upstreamBranch = split[1] return upstreamRemote, upstreamBranch, nil } func (self *UpstreamHelper) promptForUpstream(initialContent string, onConfirm func(string) error) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.EnterUpstream, InitialContent: initialContent, FindSuggestionsFunc: self.getRemoteBranchesSuggestionsFunc(" "), HandleConfirm: onConfirm, }) return nil } func (self *UpstreamHelper) PromptForUpstreamWithInitialContent(currentBranch *models.Branch, onConfirm func(string) error) error { suggestedRemote := self.GetSuggestedRemote() initialContent := suggestedRemote + " " + currentBranch.Name return self.promptForUpstream(initialContent, onConfirm) } func (self *UpstreamHelper) PromptForUpstreamWithoutInitialContent(_ *models.Branch, onConfirm func(string) error) error { return self.promptForUpstream("", onConfirm) } func (self *UpstreamHelper) GetSuggestedRemote() string { return getSuggestedRemote(self.c.Model().Remotes) } func getSuggestedRemote(remotes []*models.Remote) string { if len(remotes) == 0 { return "origin" } for _, remote := range remotes { if remote.Name == "origin" { return remote.Name } } return remotes[0].Name } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/upstream_helper_test.go000066400000000000000000000012741500612110400260760ustar00rootroot00000000000000package helpers import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/samber/lo" "github.com/stretchr/testify/assert" ) func TestGetSuggestedRemote(t *testing.T) { cases := []struct { remotes []*models.Remote expected string }{ {mkRemoteList(), "origin"}, {mkRemoteList("upstream", "origin", "foo"), "origin"}, {mkRemoteList("upstream", "foo", "bar"), "upstream"}, } for _, c := range cases { result := getSuggestedRemote(c.remotes) assert.EqualValues(t, c.expected, result) } } func mkRemoteList(names ...string) []*models.Remote { return lo.Map(names, func(name string, _ int) *models.Remote { return &models.Remote{Name: name} }) } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/view_helper.go000066400000000000000000000011441500612110400241450ustar00rootroot00000000000000package helpers import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ViewHelper struct { c *HelperCommon } func NewViewHelper(c *HelperCommon, contexts *context.ContextTree) *ViewHelper { return &ViewHelper{ c: c, } } func (self *ViewHelper) ContextForView(viewName string) (types.Context, bool) { view, err := self.c.GocuiGui().View(viewName) if err != nil { return nil, false } for _, context := range self.c.Contexts().Flatten() { if context.GetViewName() == view.Name() { return context, true } } return nil, false } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/window_arrangement_helper.go000066400000000000000000000341311500612110400270670ustar00rootroot00000000000000package helpers import ( "fmt" "math" "strings" "github.com/jesseduffield/lazycore/pkg/boxlayout" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "golang.org/x/exp/slices" ) // In this file we use the boxlayout package, along with knowledge about the app's state, // to arrange the windows (i.e. panels) on the screen. type WindowArrangementHelper struct { c *HelperCommon windowHelper *WindowHelper modeHelper *ModeHelper appStatusHelper *AppStatusHelper } func NewWindowArrangementHelper( c *HelperCommon, windowHelper *WindowHelper, modeHelper *ModeHelper, appStatusHelper *AppStatusHelper, ) *WindowArrangementHelper { return &WindowArrangementHelper{ c: c, windowHelper: windowHelper, modeHelper: modeHelper, appStatusHelper: appStatusHelper, } } type WindowArrangementArgs struct { // Width of the screen (in characters) Width int // Height of the screen (in characters) Height int // User config UserConfig *config.UserConfig // Name of the currently focused window CurrentWindow string // Name of the current static window (meaning popups are ignored) CurrentStaticWindow string // Name of the current side window (i.e. the current window in the left // section of the UI) CurrentSideWindow string // Whether the main panel is split (as is the case e.g. when a file has both // staged and unstaged changes) SplitMainPanel bool // The current screen mode (normal, half, full) ScreenMode types.ScreenMode // The content shown on the bottom left of the screen when showing a loader // or toast e.g. 'Rebasing /' AppStatus string // The content shown on the bottom right of the screen (e.g. the 'donate', // 'ask question' links or a message about the current mode e.g. rebase mode) InformationStr string // Whether to show the extras window which contains the command log context ShowExtrasWindow bool // Whether we are in a demo (which is used for generating demo gifs for the // repo's readme) InDemo bool // Whether any mode is active (e.g. rebasing, cherry picking, etc) IsAnyModeActive bool // Whether the search prompt is shown in the bottom left InSearchPrompt bool // One of '' (not searching), 'Search: ', and 'Filter: ' SearchPrefix string } func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions { width, height := self.c.GocuiGui().Size() repoState := self.c.State().GetRepoState() var searchPrefix string if repoState.GetSearchState().SearchType() == types.SearchTypeSearch { searchPrefix = self.c.Tr.SearchPrefix } else { searchPrefix = self.c.Tr.FilterPrefix } args := WindowArrangementArgs{ Width: width, Height: height, UserConfig: self.c.UserConfig(), CurrentWindow: self.windowHelper.CurrentWindow(), CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(), CurrentStaticWindow: self.c.Context().CurrentStatic().GetWindowName(), SplitMainPanel: repoState.GetSplitMainPanel(), ScreenMode: repoState.GetScreenMode(), AppStatus: appStatus, InformationStr: informationStr, ShowExtrasWindow: self.c.State().GetShowExtrasWindow(), InDemo: self.c.InDemo(), IsAnyModeActive: self.modeHelper.IsAnyModeActive(), InSearchPrompt: repoState.InSearchPrompt(), SearchPrefix: searchPrefix, } return GetWindowDimensions(args) } func shouldUsePortraitMode(args WindowArrangementArgs) bool { if args.ScreenMode == types.SCREEN_HALF { return args.UserConfig.Gui.EnlargedSideViewLocation == "top" } switch args.UserConfig.Gui.PortraitMode { case "never": return false case "always": return true default: // "auto" or any garbage values in PortraitMode value return args.Width <= 84 && args.Height > 45 } } func GetWindowDimensions(args WindowArrangementArgs) map[string]boxlayout.Dimensions { sideSectionWeight, mainSectionWeight := getMidSectionWeights(args) sidePanelsDirection := boxlayout.COLUMN if shouldUsePortraitMode(args) { sidePanelsDirection = boxlayout.ROW } showInfoSection := args.UserConfig.Gui.ShowBottomLine || args.InSearchPrompt || args.IsAnyModeActive || args.AppStatus != "" infoSectionSize := 0 if showInfoSection { infoSectionSize = 1 } root := &boxlayout.Box{ Direction: boxlayout.ROW, Children: []*boxlayout.Box{ { Direction: sidePanelsDirection, Weight: 1, Children: []*boxlayout.Box{ { Direction: boxlayout.ROW, Weight: sideSectionWeight, ConditionalChildren: sidePanelChildren(args), }, { Direction: boxlayout.ROW, Weight: mainSectionWeight, Children: mainPanelChildren(args), }, }, }, { Direction: boxlayout.COLUMN, Size: infoSectionSize, Children: infoSectionChildren(args), }, }, } layerOneWindows := boxlayout.ArrangeWindows(root, 0, 0, args.Width, args.Height) limitWindows := boxlayout.ArrangeWindows(&boxlayout.Box{Window: "limit"}, 0, 0, args.Width, args.Height) return MergeMaps(layerOneWindows, limitWindows) } func mainPanelChildren(args WindowArrangementArgs) []*boxlayout.Box { mainPanelsDirection := boxlayout.ROW if splitMainPanelSideBySide(args) { mainPanelsDirection = boxlayout.COLUMN } result := []*boxlayout.Box{ { Direction: mainPanelsDirection, Children: mainSectionChildren(args), Weight: 1, }, } if args.ShowExtrasWindow { result = append(result, &boxlayout.Box{ Window: "extras", Size: getExtrasWindowSize(args), }) } return result } func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V { result := map[K]V{} for _, currMap := range maps { for key, value := range currMap { result[key] = value } } return result } func mainSectionChildren(args WindowArrangementArgs) []*boxlayout.Box { // if we're not in split mode we can just show the one main panel. Likewise if // the main panel is focused and we're in full-screen mode if !args.SplitMainPanel || (args.ScreenMode == types.SCREEN_FULL && args.CurrentWindow == "main") { return []*boxlayout.Box{ { Window: "main", Weight: 1, }, } } if args.CurrentWindow == "secondary" && args.ScreenMode == types.SCREEN_FULL { return []*boxlayout.Box{ { Window: "secondary", Weight: 1, }, } } return []*boxlayout.Box{ { Window: "main", Weight: 1, }, { Window: "secondary", Weight: 1, }, } } func getMidSectionWeights(args WindowArrangementArgs) (int, int) { sidePanelWidthRatio := args.UserConfig.Gui.SidePanelWidth // Using 120 so that the default of 0.3333 will remain consistent with previous behavior const maxColumnCount = 120 mainSectionWeight := int(math.Round(maxColumnCount * (1 - sidePanelWidthRatio))) sideSectionWeight := int(math.Round(maxColumnCount * sidePanelWidthRatio)) if splitMainPanelSideBySide(args) { mainSectionWeight = sideSectionWeight * 5 // need to shrink side panel to make way for main panels if side-by-side } if args.CurrentWindow == "main" || args.CurrentWindow == "secondary" { if args.ScreenMode == types.SCREEN_HALF || args.ScreenMode == types.SCREEN_FULL { sideSectionWeight = 0 } } else { if args.ScreenMode == types.SCREEN_HALF { if args.UserConfig.Gui.EnlargedSideViewLocation == "top" { mainSectionWeight = sideSectionWeight * 2 } else { mainSectionWeight = sideSectionWeight } } else if args.ScreenMode == types.SCREEN_FULL { mainSectionWeight = 0 } } return sideSectionWeight, mainSectionWeight } func infoSectionChildren(args WindowArrangementArgs) []*boxlayout.Box { if args.InSearchPrompt { return []*boxlayout.Box{ { Window: "searchPrefix", Size: utils.StringWidth(args.SearchPrefix), }, { Window: "search", Weight: 1, }, } } statusSpacerPrefix := "statusSpacer" spacerBoxIndex := 0 maxSpacerBoxIndex := 2 // See pkg/gui/types/views.go // Returns a box with size 1 to be used as padding between views spacerBox := func() *boxlayout.Box { spacerBoxIndex++ if spacerBoxIndex > maxSpacerBoxIndex { panic("Too many spacer boxes") } return &boxlayout.Box{Window: fmt.Sprintf("%s%d", statusSpacerPrefix, spacerBoxIndex), Size: 1} } // Returns a box with weight 1 to be used as flexible padding between views flexibleSpacerBox := func() *boxlayout.Box { spacerBoxIndex++ if spacerBoxIndex > maxSpacerBoxIndex { panic("Too many spacer boxes") } return &boxlayout.Box{Window: fmt.Sprintf("%s%d", statusSpacerPrefix, spacerBoxIndex), Weight: 1} } // Adds spacer boxes inbetween given boxes insertSpacerBoxes := func(boxes []*boxlayout.Box) []*boxlayout.Box { for i := len(boxes) - 1; i >= 1; i-- { // ignore existing spacer boxes if !strings.HasPrefix(boxes[i].Window, statusSpacerPrefix) { boxes = slices.Insert(boxes, i, spacerBox()) } } return boxes } // First collect the real views that we want to show, we'll add spacers in // between at the end var result []*boxlayout.Box if !args.InDemo { // app status appears very briefly in demos and dislodges the caption, // so better not to show it at all if args.AppStatus != "" { result = append(result, &boxlayout.Box{Window: "appStatus", Size: utils.StringWidth(args.AppStatus)}) } } if args.UserConfig.Gui.ShowBottomLine { result = append(result, &boxlayout.Box{Window: "options", Weight: 1}) } if (!args.InDemo && args.UserConfig.Gui.ShowBottomLine) || args.IsAnyModeActive { result = append(result, &boxlayout.Box{ Window: "information", // unlike appStatus, informationStr has various colors so we need to decolorise before taking the length Size: utils.StringWidth(utils.Decolorise(args.InformationStr)), }) } if len(result) == 2 && result[0].Window == "appStatus" { // Only status and information are showing; need to insert a flexible // spacer between the two, so that information is right-aligned. Note // that the call to insertSpacerBoxes below will still insert a 1-char // spacer in addition (right after the flexible one); this is needed for // the case that there's not enough room, to ensure there's always at // least one space. result = slices.Insert(result, 1, flexibleSpacerBox()) } else if len(result) == 1 { if result[0].Window == "information" { // Only information is showing; need to add a flexible spacer so // that information is right-aligned result = slices.Insert(result, 0, flexibleSpacerBox()) } else { // Only status is showing; need to make it flexible so that it // extends over the whole width result[0].Size = 0 result[0].Weight = 1 } } if len(result) > 0 { // If we have at least one view, insert 1-char wide spacer boxes between them. result = insertSpacerBoxes(result) } return result } func splitMainPanelSideBySide(args WindowArrangementArgs) bool { if !args.SplitMainPanel { return false } mainPanelSplitMode := args.UserConfig.Gui.MainPanelSplitMode switch mainPanelSplitMode { case "vertical": return false case "horizontal": return true default: if args.Width < 200 && args.Height > 30 { // 2 80 character width panels + 40 width for side panel return false } else { return true } } } func getExtrasWindowSize(args WindowArrangementArgs) int { var baseSize int // The 'extras' window contains the command log context if args.CurrentStaticWindow == "extras" { baseSize = 1000 // my way of saying 'fill the available space' } else if args.Height < 40 { baseSize = 1 } else { baseSize = args.UserConfig.Gui.CommandLogSize } frameSize := 2 return baseSize + frameSize } // The stash window by default only contains one line so that it's not hogging // too much space, but if you access it it should take up some space. This is // the default behaviour when accordion mode is NOT in effect. If it is in effect // then when it's accessed it will have weight 2, not 1. func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box { box := &boxlayout.Box{Window: "stash"} // if the stash window is anywhere in our stack we should enlargen it if args.CurrentSideWindow == "stash" { box.Weight = 1 } else { box.Size = 3 } return box } func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) []*boxlayout.Box { return func(width int, height int) []*boxlayout.Box { if args.ScreenMode == types.SCREEN_FULL || args.ScreenMode == types.SCREEN_HALF { fullHeightBox := func(window string) *boxlayout.Box { if window == args.CurrentSideWindow { return &boxlayout.Box{ Window: window, Weight: 1, } } else { return &boxlayout.Box{ Window: window, Size: 0, } } } return []*boxlayout.Box{ fullHeightBox("status"), fullHeightBox("files"), fullHeightBox("branches"), fullHeightBox("commits"), fullHeightBox("stash"), } } else if height >= 28 { accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box { if accordionMode && defaultBox.Window == args.CurrentSideWindow { return &boxlayout.Box{ Window: defaultBox.Window, Weight: args.UserConfig.Gui.ExpandedSidePanelWeight, } } return defaultBox } return []*boxlayout.Box{ { Window: "status", Size: 3, }, accordionBox(&boxlayout.Box{Window: "files", Weight: 1}), accordionBox(&boxlayout.Box{Window: "branches", Weight: 1}), accordionBox(&boxlayout.Box{Window: "commits", Weight: 1}), accordionBox(getDefaultStashWindowBox(args)), } } else { squashedHeight := 1 if height >= 21 { squashedHeight = 3 } squashedSidePanelBox := func(window string) *boxlayout.Box { if window == args.CurrentSideWindow { return &boxlayout.Box{ Window: window, Weight: 1, } } else { return &boxlayout.Box{ Window: window, Size: squashedHeight, } } } return []*boxlayout.Box{ squashedSidePanelBox("status"), squashedSidePanelBox("files"), squashedSidePanelBox("branches"), squashedSidePanelBox("commits"), squashedSidePanelBox("stash"), } } } } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/window_arrangement_helper_test.go000066400000000000000000001210141500612110400301230ustar00rootroot00000000000000package helpers import ( "cmp" "fmt" "slices" "strings" "testing" "github.com/jesseduffield/lazycore/pkg/boxlayout" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) // The best way to add test cases here is to set your args and then get the // test to fail and copy+paste the output into the test case's expected string. // TODO: add more test cases func TestGetWindowDimensions(t *testing.T) { getDefaultArgs := func() WindowArrangementArgs { return WindowArrangementArgs{ Width: 75, Height: 30, UserConfig: config.GetDefaultConfig(), CurrentWindow: "files", CurrentSideWindow: "files", CurrentStaticWindow: "files", SplitMainPanel: false, ScreenMode: types.SCREEN_NORMAL, AppStatus: "", InformationStr: "information", ShowExtrasWindow: false, InDemo: false, IsAnyModeActive: false, InSearchPrompt: false, SearchPrefix: "", } } type Test struct { name string mutateArgs func(*WindowArrangementArgs) expected string } tests := []Test{ { name: "default", mutateArgs: func(args *WindowArrangementArgs) {}, expected: ` ╭status─────────────────╮╭main────────────────────────────────────────────╮ │ ││ │ ╰───────────────────────╯│ │ ╭files──────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭branches───────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭commits────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭stash──────────────────╮│ │ │ ││ │ ╰───────────────────────╯╰────────────────────────────────────────────────╯ A A: statusSpacer1 B: information `, }, { name: "stash focused", mutateArgs: func(args *WindowArrangementArgs) { args.CurrentSideWindow = "stash" }, expected: ` ╭status─────────────────╮╭main────────────────────────────────────────────╮ │ ││ │ ╰───────────────────────╯│ │ ╭files──────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭branches───────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭commits────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭stash──────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯╰────────────────────────────────────────────────╯ A A: statusSpacer1 B: information `, }, { name: "expandFocusedSidePanel", mutateArgs: func(args *WindowArrangementArgs) { args.UserConfig.Gui.ExpandFocusedSidePanel = true }, expected: ` ╭status─────────────────╮╭main────────────────────────────────────────────╮ │ ││ │ ╰───────────────────────╯│ │ ╭files──────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭branches───────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭commits────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭stash──────────────────╮│ │ │ ││ │ ╰───────────────────────╯╰────────────────────────────────────────────────╯ A A: statusSpacer1 B: information `, }, { name: "expandSidePanelWeight", mutateArgs: func(args *WindowArrangementArgs) { args.UserConfig.Gui.ExpandFocusedSidePanel = true args.UserConfig.Gui.ExpandedSidePanelWeight = 4 }, expected: ` ╭status─────────────────╮╭main────────────────────────────────────────────╮ │ ││ │ ╰───────────────────────╯│ │ ╭files──────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭branches───────────────╮│ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭commits────────────────╮│ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭stash──────────────────╮│ │ │ ││ │ ╰───────────────────────╯╰────────────────────────────────────────────────╯ A A: statusSpacer1 B: information `, }, { name: "0.5 SidePanelWidth", mutateArgs: func(args *WindowArrangementArgs) { args.UserConfig.Gui.SidePanelWidth = 0.5 }, expected: ` ╭status──────────────────────────────╮╭main───────────────────────────────╮ │ ││ │ ╰────────────────────────────────────╯│ │ ╭files───────────────────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰────────────────────────────────────╯│ │ ╭branches────────────────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰────────────────────────────────────╯│ │ ╭commits─────────────────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰────────────────────────────────────╯│ │ ╭stash───────────────────────────────╮│ │ │ ││ │ ╰────────────────────────────────────╯╰───────────────────────────────────╯ A A: statusSpacer1 B: information `, }, { name: "0.8 SidePanelWidth", mutateArgs: func(args *WindowArrangementArgs) { args.UserConfig.Gui.SidePanelWidth = 0.8 }, expected: ` ╭status────────────────────────────────────────────────────╮╭main─────────╮ │ ││ │ ╰──────────────────────────────────────────────────────────╯│ │ ╭files─────────────────────────────────────────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰──────────────────────────────────────────────────────────╯│ │ ╭branches──────────────────────────────────────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰──────────────────────────────────────────────────────────╯│ │ ╭commits───────────────────────────────────────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰──────────────────────────────────────────────────────────╯│ │ ╭stash─────────────────────────────────────────────────────╮│ │ │ ││ │ ╰──────────────────────────────────────────────────────────╯╰─────────────╯ A A: statusSpacer1 B: information `, }, { name: "half screen mode, enlargedSideViewLocation left", mutateArgs: func(args *WindowArrangementArgs) { args.Height = 20 // smaller height because we don't more here args.ScreenMode = types.SCREEN_HALF args.UserConfig.Gui.EnlargedSideViewLocation = "left" }, expected: ` ╭status──────────────────────────────╮╭main───────────────────────────────╮ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰────────────────────────────────────╯╰───────────────────────────────────╯ A A: statusSpacer1 B: information `, }, { name: "half screen mode, enlargedSideViewLocation top", mutateArgs: func(args *WindowArrangementArgs) { args.Height = 20 // smaller height because we don't more here args.ScreenMode = types.SCREEN_HALF args.UserConfig.Gui.EnlargedSideViewLocation = "top" }, expected: ` ╭status───────────────────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭main─────────────────────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰─────────────────────────────────────────────────────────────────────────╯ A A: statusSpacer1 B: information `, }, { name: "search mode", mutateArgs: func(args *WindowArrangementArgs) { args.InSearchPrompt = true args.SearchPrefix = "Search: " args.Height = 6 // small height cos we only care about the bottom line }, expected: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ A: searchPrefix `, }, { name: "app status present", mutateArgs: func(args *WindowArrangementArgs) { args.AppStatus = "Rebasing /" args.Height = 6 // small height cos we only care about the bottom line }, // We expect single-character spacers between the windows of the bottom line expected: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ BC A: appStatus B: statusSpacer2 C: statusSpacer1 D: information `, }, { name: "information present without options", mutateArgs: func(args *WindowArrangementArgs) { args.Height = 6 // small height cos we only care about the bottom line args.UserConfig.Gui.ShowBottomLine = false // this hides the options window args.IsAnyModeActive = true // this means we show the bottom line despite the user config }, // We expect a spacer on the left of the bottom line so that the information // window is right-aligned expected: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ A A: statusSpacer2 B: information `, }, { name: "app status present without information or options", mutateArgs: func(args *WindowArrangementArgs) { args.Height = 6 // small height cos we only care about the bottom line args.UserConfig.Gui.ShowBottomLine = false // this hides the options window args.IsAnyModeActive = false args.AppStatus = "Rebasing /" }, // We expect the app status window to take up all the available space expected: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ `, }, { name: "app status present with information but without options", mutateArgs: func(args *WindowArrangementArgs) { args.Height = 6 // small height cos we only care about the bottom line args.UserConfig.Gui.ShowBottomLine = false // this hides the options window args.IsAnyModeActive = true args.AppStatus = "Rebasing /" }, expected: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ B A: appStatus B: statusSpacer2 C: information `, }, { name: "app status present with very long information but without options", mutateArgs: func(args *WindowArrangementArgs) { args.Height = 6 // small height cos we only care about the bottom line args.Width = 55 // smaller width so that not all bottom line views fit args.UserConfig.Gui.ShowBottomLine = false // this hides the options window args.IsAnyModeActive = true args.AppStatus = "Rebasing /" args.InformationStr = "Showing output for: git diff deadbeef fa1afe1 -- (Reset)" }, expected: ` ╭main──────────────────────────────╮ │ │ │ │ │ │ ╰──────────────────────────────────╯ B A: appStatus B: statusSpacer2 `, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { args := getDefaultArgs() test.mutateArgs(&args) windows := GetWindowDimensions(args) output := renderLayout(windows) // removing tabs so that it's easier to paste the expected output expected := strings.ReplaceAll(test.expected, "\t", "") expected = strings.TrimSpace(expected) if output != expected { fmt.Println(output) t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, output) } }) } } func renderLayout(windows map[string]boxlayout.Dimensions) string { // Each window will be represented by a letter. windowMarkers := map[string]string{} shortLabels := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"} currentShortLabelIdx := 0 windowNames := lo.Keys(windows) // Sort first by name, then by position. This means our short labels will // increment in the order that the windows appear on the screen. slices.Sort(windowNames) slices.SortStableFunc(windowNames, func(a, b string) int { dimensionsA := windows[a] dimensionsB := windows[b] if dimensionsA.Y0 != dimensionsB.Y0 { return cmp.Compare(dimensionsA.Y0, dimensionsB.Y0) } return cmp.Compare(dimensionsA.X0, dimensionsB.X0) }) // Uniquify windows by dimensions (so perfectly overlapping windows are de-duped). This prevents getting 'fileshes' as a label where the files and branches windows overlap. // branches windows overlap. windowNames = lo.UniqBy(windowNames, func(windowName string) boxlayout.Dimensions { return windows[windowName] }) // excluding the limit window because it overlaps with everything. In future // we should have a concept of layers and then our test can assert against // each layer. windowNames = lo.Without(windowNames, "limit") // get width/height by getting the max values of the dimensions width := 0 height := 0 for _, dimensions := range windows { if dimensions.X1+1 > width { width = dimensions.X1 + 1 } if dimensions.Y1+1 > height { height = dimensions.Y1 + 1 } } screen := make([][]string, height) for i := range screen { screen[i] = make([]string, width) } // Draw each window for _, windowName := range windowNames { dimensions := windows[windowName] zeroWidth := dimensions.X0 == dimensions.X1+1 if zeroWidth { continue } singleRow := dimensions.Y0 == dimensions.Y1 oneOrTwoColumns := dimensions.X0 == dimensions.X1 || dimensions.X0+1 == dimensions.X1 assignShortLabel := func(windowName string) string { windowMarkers[windowName] = shortLabels[currentShortLabelIdx] currentShortLabelIdx++ return windowMarkers[windowName] } if singleRow { y := dimensions.Y0 // If our window only occupies one (or two) columns we'll just use the short // label once (or twice) i.e. 'A' or 'AA'. if oneOrTwoColumns { shortLabel := assignShortLabel(windowName) for x := dimensions.X0; x <= dimensions.X1; x++ { screen[y][x] = shortLabel } } else { screen[y][dimensions.X0] = "<" screen[y][dimensions.X1] = ">" for x := dimensions.X0 + 1; x < dimensions.X1; x++ { screen[y][x] = "─" } // Now add the label label := windowName // If we can't fit the label we'll use a one-character short label if len(label) > dimensions.X1-dimensions.X0-1 { label = assignShortLabel(windowName) } for i, char := range label { screen[y][dimensions.X0+1+i] = string(char) } } } else { // Draw box border for y := dimensions.Y0; y <= dimensions.Y1; y++ { for x := dimensions.X0; x <= dimensions.X1; x++ { if x == dimensions.X0 && y == dimensions.Y0 { screen[y][x] = "╭" } else if x == dimensions.X1 && y == dimensions.Y0 { screen[y][x] = "╮" } else if x == dimensions.X0 && y == dimensions.Y1 { screen[y][x] = "╰" } else if x == dimensions.X1 && y == dimensions.Y1 { screen[y][x] = "╯" } else if y == dimensions.Y0 || y == dimensions.Y1 { screen[y][x] = "─" } else if x == dimensions.X0 || x == dimensions.X1 { screen[y][x] = "│" } else { screen[y][x] = " " } } } // Add the label label := windowName // If we can't fit the label we'll use a one-character short label if len(label) > dimensions.X1-dimensions.X0-1 { label = assignShortLabel(windowName) } for i, char := range label { screen[dimensions.Y0][dimensions.X0+1+i] = string(char) } } } // Draw the screen output := "" for _, row := range screen { for _, marker := range row { output += marker } output += "\n" } // Add a legend for _, windowName := range windowNames { if !lo.Contains(lo.Keys(windowMarkers), windowName) { continue } marker := windowMarkers[windowName] output += fmt.Sprintf("%s: %s\n", marker, windowName) } output = strings.TrimSpace(output) return output } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/window_helper.go000066400000000000000000000077051500612110400245130ustar00rootroot00000000000000package helpers import ( "fmt" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type WindowHelper struct { c *HelperCommon viewHelper *ViewHelper } func NewWindowHelper(c *HelperCommon, viewHelper *ViewHelper) *WindowHelper { return &WindowHelper{ c: c, viewHelper: viewHelper, } } // A window refers to a place on the screen which can hold one or more views. // A view is a box that renders content, and within a window only one view will // appear at a time. When a view appears within a window, it occupies the whole // space. Right now most windows are 1:1 with views, except for commitFiles which // is a view that moves between windows func (self *WindowHelper) GetViewNameForWindow(window string) string { viewName, ok := self.windowViewNameMap().Get(window) if !ok { panic(fmt.Sprintf("Viewname not found for window: %s", window)) } return viewName } func (self *WindowHelper) GetContextForWindow(window string) types.Context { viewName := self.GetViewNameForWindow(window) context, ok := self.viewHelper.ContextForView(viewName) if !ok { panic("TODO: fix this") } return context } // for now all we actually care about is the context's view so we're storing that func (self *WindowHelper) SetWindowContext(c types.Context) { if c.IsTransient() { self.resetWindowContext(c) } self.windowViewNameMap().Set(c.GetWindowName(), c.GetViewName()) } func (self *WindowHelper) windowViewNameMap() *utils.ThreadSafeMap[string, string] { return self.c.State().GetRepoState().GetWindowViewNameMap() } func (self *WindowHelper) CurrentWindow() string { return self.c.Context().Current().GetWindowName() } // assumes the context's windowName has been set to the new window if necessary func (self *WindowHelper) resetWindowContext(c types.Context) { for _, windowName := range self.windowViewNameMap().Keys() { viewName, ok := self.windowViewNameMap().Get(windowName) if !ok { continue } if viewName == c.GetViewName() && windowName != c.GetWindowName() { for _, context := range self.c.Contexts().Flatten() { if context.GetKey() != c.GetKey() && context.GetWindowName() == windowName { self.windowViewNameMap().Set(windowName, context.GetViewName()) } } } } } // moves given context's view to the top of the window func (self *WindowHelper) MoveToTopOfWindow(context types.Context) { view := context.GetView() if view == nil { return } window := context.GetWindowName() topView := self.TopViewInWindow(window, true) if topView != nil && view.Name() != topView.Name() { if err := self.c.GocuiGui().SetViewOnTopOf(view.Name(), topView.Name()); err != nil { self.c.Log.Error(err) } } } func (self *WindowHelper) TopViewInWindow(windowName string, includeInvisibleViews bool) *gocui.View { // now I need to find all views in that same window, via contexts. And I guess then I need to find the index of the highest view in that list. viewNamesInWindow := self.viewNamesInWindow(windowName) // The views list is ordered highest-last, so we're grabbing the last view of the window var topView *gocui.View for _, currentView := range self.c.GocuiGui().Views() { if lo.Contains(viewNamesInWindow, currentView.Name()) && (currentView.Visible || includeInvisibleViews) { topView = currentView } } return topView } func (self *WindowHelper) viewNamesInWindow(windowName string) []string { result := []string{} for _, context := range self.c.Contexts().Flatten() { if context.GetWindowName() == windowName { result = append(result, context.GetViewName()) } } return result } func (self *WindowHelper) WindowForView(viewName string) string { context, ok := self.viewHelper.ContextForView(viewName) if !ok { panic("todo: deal with this") } return context.GetWindowName() } func (self *WindowHelper) SideWindows() []string { return []string{"status", "files", "branches", "commits", "stash"} } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/working_tree_helper.go000066400000000000000000000165221500612110400257000ustar00rootroot00000000000000package helpers import ( "errors" "fmt" "regexp" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type WorkingTreeHelper struct { c *HelperCommon refHelper *RefsHelper commitsHelper *CommitsHelper gpgHelper *GpgHelper } func NewWorkingTreeHelper( c *HelperCommon, refHelper *RefsHelper, commitsHelper *CommitsHelper, gpgHelper *GpgHelper, ) *WorkingTreeHelper { return &WorkingTreeHelper{ c: c, refHelper: refHelper, commitsHelper: commitsHelper, gpgHelper: gpgHelper, } } func (self *WorkingTreeHelper) AnyStagedFiles() bool { return AnyStagedFiles(self.c.Model().Files) } func AnyStagedFiles(files []*models.File) bool { return lo.SomeBy(files, func(f *models.File) bool { return f.HasStagedChanges }) } func (self *WorkingTreeHelper) AnyTrackedFiles() bool { return AnyTrackedFiles(self.c.Model().Files) } func AnyTrackedFiles(files []*models.File) bool { return lo.SomeBy(files, func(f *models.File) bool { return f.Tracked }) } func (self *WorkingTreeHelper) IsWorkingTreeDirty() bool { return IsWorkingTreeDirty(self.c.Model().Files) } func IsWorkingTreeDirty(files []*models.File) bool { return AnyStagedFiles(files) || AnyTrackedFiles(files) } func (self *WorkingTreeHelper) FileForSubmodule(submodule *models.SubmoduleConfig) *models.File { for _, file := range self.c.Model().Files { if file.IsSubmodule([]*models.SubmoduleConfig{submodule}) { return file } } return nil } func (self *WorkingTreeHelper) OpenMergeTool() error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.MergeToolTitle, Prompt: self.c.Tr.MergeToolPrompt, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.OpenMergeTool) return self.c.RunSubprocessAndRefresh( self.c.Git().WorkingTree.OpenMergeToolCmdObj(), ) }, }) return nil } func (self *WorkingTreeHelper) HandleCommitPressWithMessage(initialMessage string, forceSkipHooks bool) error { return self.WithEnsureCommittableFiles(func() error { self.commitsHelper.OpenCommitMessagePanel( &OpenCommitMessagePanelOpts{ CommitIndex: context.NoCommitIndex, InitialMessage: initialMessage, SummaryTitle: self.c.Tr.CommitSummaryTitle, DescriptionTitle: self.c.Tr.CommitDescriptionTitle, PreserveMessage: true, OnConfirm: func(summary string, description string) error { return self.handleCommit(summary, description, forceSkipHooks) }, OnSwitchToEditor: func(filepath string) error { return self.switchFromCommitMessagePanelToEditor(filepath, forceSkipHooks) }, ForceSkipHooks: forceSkipHooks, SkipHooksPrefix: self.c.UserConfig().Git.SkipHookPrefix, }, ) return nil }) } func (self *WorkingTreeHelper) handleCommit(summary string, description string, forceSkipHooks bool) error { cmdObj := self.c.Git().Commit.CommitCmdObj(summary, description, forceSkipHooks) self.c.LogAction(self.c.Tr.Actions.Commit) return self.gpgHelper.WithGpgHandling(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus, func() error { self.commitsHelper.OnCommitSuccess() return nil }, nil) } func (self *WorkingTreeHelper) switchFromCommitMessagePanelToEditor(filepath string, forceSkipHooks bool) error { // We won't be able to tell whether the commit was successful, because // RunSubprocessAndRefresh doesn't return the error (it opens an error alert // itself and returns nil on error). But even if we could, we wouldn't have // access to the last message that the user typed, and it might be very // different from what was last in the commit panel. So the best we can do // here is to always clear the remembered commit message. self.commitsHelper.OnCommitSuccess() self.c.LogAction(self.c.Tr.Actions.Commit) return self.c.RunSubprocessAndRefresh( self.c.Git().Commit.CommitInEditorWithMessageFileCmdObj(filepath, forceSkipHooks), ) } // HandleCommitEditorPress - handle when the user wants to commit changes via // their editor rather than via the popup panel func (self *WorkingTreeHelper) HandleCommitEditorPress() error { return self.WithEnsureCommittableFiles(func() error { self.c.LogAction(self.c.Tr.Actions.Commit) return self.c.RunSubprocessAndRefresh( self.c.Git().Commit.CommitEditorCmdObj(), ) }) } func (self *WorkingTreeHelper) HandleWIPCommitPress() error { var initialMessage string preservedMessage := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError() if preservedMessage == "" { // Use the skipHook prefix only if we don't have a preserved message initialMessage = self.c.UserConfig().Git.SkipHookPrefix } return self.HandleCommitPressWithMessage(initialMessage, true) } func (self *WorkingTreeHelper) HandleCommitPress() error { message := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError() if message == "" { commitPrefixConfigs := self.commitPrefixConfigsForRepo() for _, commitPrefixConfig := range commitPrefixConfigs { prefixPattern := commitPrefixConfig.Pattern if prefixPattern == "" { continue } prefixReplace := commitPrefixConfig.Replace branchName := self.refHelper.GetCheckedOutRef().Name rgx, err := regexp.Compile(prefixPattern) if err != nil { return fmt.Errorf("%s: %s", self.c.Tr.CommitPrefixPatternError, err.Error()) } if rgx.MatchString(branchName) { prefix := rgx.ReplaceAllString(branchName, prefixReplace) message = prefix break } } } return self.HandleCommitPressWithMessage(message, false) } func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) error { if err := self.prepareFilesForCommit(); err != nil { return err } if len(self.c.Model().Files) == 0 { return errors.New(self.c.Tr.NoFilesStagedTitle) } if !self.AnyStagedFiles() { return self.promptToStageAllAndRetry(handler) } return handler() } func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.NoFilesStagedTitle, Prompt: self.c.Tr.NoFilesStagedPrompt, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.StageAllFiles) if err := self.c.Git().WorkingTree.StageAll(); err != nil { return err } if err := self.syncRefresh(); err != nil { return err } return retry() }, }) return nil } // for when you need to refetch files before continuing an action. Runs synchronously. func (self *WorkingTreeHelper) syncRefresh() error { return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) } func (self *WorkingTreeHelper) prepareFilesForCommit() error { noStagedFiles := !self.AnyStagedFiles() if noStagedFiles && self.c.UserConfig().Gui.SkipNoStagedFilesWarning { self.c.LogAction(self.c.Tr.Actions.StageAllFiles) err := self.c.Git().WorkingTree.StageAll() if err != nil { return err } return self.syncRefresh() } return nil } func (self *WorkingTreeHelper) commitPrefixConfigsForRepo() []config.CommitPrefixConfig { cfg, ok := self.c.UserConfig().Git.CommitPrefixes[self.c.Git().RepoPaths.RepoName()] if ok { return append(cfg, self.c.UserConfig().Git.CommitPrefix...) } else { return self.c.UserConfig().Git.CommitPrefix } } lazygit-0.50.0+ds1/pkg/gui/controllers/helpers/worktree_helper.go000066400000000000000000000156151500612110400250450ustar00rootroot00000000000000package helpers import ( "errors" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type WorktreeHelper struct { c *HelperCommon reposHelper *ReposHelper refsHelper *RefsHelper suggestionsHelper *SuggestionsHelper } func NewWorktreeHelper(c *HelperCommon, reposHelper *ReposHelper, refsHelper *RefsHelper, suggestionsHelper *SuggestionsHelper) *WorktreeHelper { return &WorktreeHelper{ c: c, reposHelper: reposHelper, refsHelper: refsHelper, suggestionsHelper: suggestionsHelper, } } func (self *WorktreeHelper) GetMainWorktreeName() string { for _, worktree := range self.c.Model().Worktrees { if worktree.IsMain { return worktree.Name } } return "" } // If we're on the main worktree, we return an empty string func (self *WorktreeHelper) GetLinkedWorktreeName() string { worktrees := self.c.Model().Worktrees if len(worktrees) == 0 { return "" } // worktrees always have the current worktree on top currentWorktree := worktrees[0] if currentWorktree.IsMain { return "" } return currentWorktree.Name } func (self *WorktreeHelper) NewWorktree() error { branch := self.refsHelper.GetCheckedOutRef() currentBranchName := branch.RefName() f := func(detached bool) { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewWorktreeBase, InitialContent: currentBranchName, FindSuggestionsFunc: self.suggestionsHelper.GetRefsSuggestionsFunc(), HandleConfirm: func(base string) error { // we assume that the base can be checked out canCheckoutBase := true return self.NewWorktreeCheckout(base, canCheckoutBase, detached, context.WORKTREES_CONTEXT_KEY) }, }) } placeholders := map[string]string{"ref": "ref"} return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.WorktreeTitle, Items: []*types.MenuItem{ { LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFrom, placeholders)}, OnPress: func() error { f(false) return nil }, }, { LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFromDetached, placeholders)}, OnPress: func() error { f(true) return nil }, }, }, }) } func (self *WorktreeHelper) NewWorktreeCheckout(base string, canCheckoutBase bool, detached bool, contextKey types.ContextKey) error { opts := git_commands.NewWorktreeOpts{ Base: base, Detach: detached, } f := func() error { return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddWorktree) if err := self.c.Git().Worktree.New(opts); err != nil { return err } return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) }) } self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewWorktreePath, HandleConfirm: func(path string) error { opts.Path = path if detached { return f() } if canCheckoutBase { title := utils.ResolvePlaceholderString(self.c.Tr.NewBranchNameLeaveBlank, map[string]string{"default": base}) // prompt for the new branch name where a blank means we just check out the branch self.c.Prompt(types.PromptOpts{ Title: title, HandleConfirm: func(branchName string) error { opts.Branch = branchName return f() }, }) return nil } else { // prompt for the new branch name where a blank means we just check out the branch self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewBranchName, HandleConfirm: func(branchName string) error { if branchName == "" { return errors.New(self.c.Tr.BranchNameCannotBeBlank) } opts.Branch = branchName return f() }, }) return nil } }, }) return nil } func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.ContextKey) error { if worktree.IsCurrent { return errors.New(self.c.Tr.AlreadyInWorktree) } self.c.LogAction(self.c.Tr.SwitchToWorktree) return self.reposHelper.DispatchSwitchTo(worktree.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) } func (self *WorktreeHelper) Remove(worktree *models.Worktree, force bool) error { title := self.c.Tr.RemoveWorktreeTitle var templateStr string if force { templateStr = self.c.Tr.ForceRemoveWorktreePrompt } else { templateStr = self.c.Tr.RemoveWorktreePrompt } message := utils.ResolvePlaceholderString( templateStr, map[string]string{ "worktreeName": worktree.Name, }, ) self.c.Confirm(types.ConfirmOpts{ Title: title, Prompt: message, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(gocui.Task) error { self.c.LogAction(self.c.Tr.RemoveWorktree) if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil { errMessage := err.Error() if !strings.Contains(errMessage, "--force") { return err } if !force { return self.Remove(worktree, true) } return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) }) }, }) return nil } func (self *WorktreeHelper) Detach(worktree *models.Worktree) error { return self.c.WithWaitingStatus(self.c.Tr.DetachingWorktree, func(gocui.Task) error { self.c.LogAction(self.c.Tr.RemovingWorktree) err := self.c.Git().Worktree.Detach(worktree.Path) if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) }) } func (self *WorktreeHelper) ViewWorktreeOptions(context types.IListContext, ref string) error { currentBranch := self.refsHelper.GetCheckedOutRef() canCheckoutBase := context == self.c.Contexts().Branches && ref != currentBranch.RefName() return self.ViewBranchWorktreeOptions(ref, canCheckoutBase) } func (self *WorktreeHelper) ViewBranchWorktreeOptions(branchName string, canCheckoutBase bool) error { placeholders := map[string]string{"ref": branchName} return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.WorktreeTitle, Items: []*types.MenuItem{ { LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFrom, placeholders)}, OnPress: func() error { return self.NewWorktreeCheckout(branchName, canCheckoutBase, false, context.LOCAL_BRANCHES_CONTEXT_KEY) }, }, { LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFromDetached, placeholders)}, OnPress: func() error { return self.NewWorktreeCheckout(branchName, canCheckoutBase, true, context.LOCAL_BRANCHES_CONTEXT_KEY) }, }, }, }) } lazygit-0.50.0+ds1/pkg/gui/controllers/jump_to_side_window_controller.go000066400000000000000000000032261500612110400265100ustar00rootroot00000000000000package controllers import ( "log" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type JumpToSideWindowController struct { baseController c *ControllerCommon nextTabFunc func() error } func NewJumpToSideWindowController( c *ControllerCommon, nextTabFunc func() error, ) *JumpToSideWindowController { return &JumpToSideWindowController{ baseController: baseController{}, c: c, nextTabFunc: nextTabFunc, } } func (self *JumpToSideWindowController) Context() types.Context { return nil } func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { windows := self.c.Helpers().Window.SideWindows() if len(opts.Config.Universal.JumpToBlock) != len(windows) { log.Fatal("Jump to block keybindings cannot be set. Exactly 5 keybindings must be supplied.") } return lo.Map(windows, func(window string, index int) *types.Binding { return &types.Binding{ ViewName: "", // by default the keys are 1, 2, 3, etc Key: opts.GetKey(opts.Config.Universal.JumpToBlock[index]), Modifier: gocui.ModNone, Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)), } }) } func (self *JumpToSideWindowController) goToSideWindow(window string) func() error { return func() error { sideWindowAlreadyActive := self.c.Helpers().Window.CurrentWindow() == window if sideWindowAlreadyActive && self.c.UserConfig().Gui.SwitchTabsWithPanelJumpKeys { return self.nextTabFunc() } context := self.c.Helpers().Window.GetContextForWindow(window) self.c.Context().Push(context, types.OnFocusOpts{}) return nil } } lazygit-0.50.0+ds1/pkg/gui/controllers/list_controller.go000066400000000000000000000173511500612110400234170ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ListControllerFactory struct { c *ControllerCommon } func NewListControllerFactory(c *ControllerCommon) *ListControllerFactory { return &ListControllerFactory{ c: c, } } func (self *ListControllerFactory) Create(context types.IListContext) *ListController { return &ListController{ baseController: baseController{}, c: self.c, context: context, } } type ListController struct { baseController c *ControllerCommon context types.IListContext } func (self *ListController) Context() types.Context { return self.context } func (self *ListController) HandlePrevLine() error { return self.handleLineChange(-1) } func (self *ListController) HandleNextLine() error { return self.handleLineChange(1) } func (self *ListController) HandleScrollLeft() error { return self.scrollHorizontal(self.context.GetViewTrait().ScrollLeft) } func (self *ListController) HandleScrollRight() error { return self.scrollHorizontal(self.context.GetViewTrait().ScrollRight) } func (self *ListController) HandleScrollUp() error { scrollHeight := self.c.UserConfig().Gui.ScrollHeight self.context.GetViewTrait().ScrollUp(scrollHeight) if self.context.RenderOnlyVisibleLines() { self.context.HandleRender() } return nil } func (self *ListController) HandleScrollDown() error { scrollHeight := self.c.UserConfig().Gui.ScrollHeight self.context.GetViewTrait().ScrollDown(scrollHeight) if self.context.RenderOnlyVisibleLines() { self.context.HandleRender() } return nil } func (self *ListController) scrollHorizontal(scrollFunc func()) error { scrollFunc() self.context.HandleFocus(types.OnFocusOpts{}) if self.context.NeedsRerenderOnWidthChange() == types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES { self.context.HandleRender() } return nil } func (self *ListController) handleLineChange(change int) error { return self.handleLineChangeAux( self.context.GetList().MoveSelectedLine, change, ) } func (self *ListController) HandleRangeSelectChange(change int) error { return self.handleLineChangeAux( self.context.GetList().ExpandNonStickyRange, change, ) } func (self *ListController) handleLineChangeAux(f func(int), change int) error { list := self.context.GetList() rangeBefore := list.IsSelectingRange() before := list.GetSelectedLineIdx() f(change) rangeAfter := list.IsSelectingRange() after := list.GetSelectedLineIdx() if err := self.pushContextIfNotFocused(); err != nil { return err } // doing this check so that if we're holding the up key at the start of the list // we're not constantly re-rendering the main view. cursorMoved := before != after if cursorMoved { if change == -1 { checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after)) } else if change == 1 { checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after)) } } if cursorMoved || rangeBefore != rangeAfter { self.context.HandleFocus(types.OnFocusOpts{}) } return nil } func (self *ListController) HandlePrevPage() error { return self.handleLineChange(-self.context.GetViewTrait().PageDelta()) } func (self *ListController) HandleNextPage() error { return self.handleLineChange(self.context.GetViewTrait().PageDelta()) } func (self *ListController) HandleGotoTop() error { return self.handleLineChange(-self.context.GetList().Len()) } func (self *ListController) HandleGotoBottom() error { return self.handleLineChange(self.context.GetList().Len()) } func (self *ListController) HandleToggleRangeSelect() error { list := self.context.GetList() list.ToggleStickyRange() self.context.HandleFocus(types.OnFocusOpts{}) return nil } func (self *ListController) HandleRangeSelectDown() error { return self.HandleRangeSelectChange(1) } func (self *ListController) HandleRangeSelectUp() error { return self.HandleRangeSelectChange(-1) } func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error { prevSelectedLineIdx := self.context.GetList().GetSelectedLineIdx() newSelectedLineIdx := self.context.ViewIndexToModelIndex(opts.Y) alreadyFocused := self.isFocused() if err := self.pushContextIfNotFocused(); err != nil { return err } if newSelectedLineIdx > self.context.GetList().Len()-1 { return nil } self.context.GetList().SetSelection(newSelectedLineIdx) if prevSelectedLineIdx == newSelectedLineIdx && alreadyFocused && self.context.GetOnClick() != nil { return self.context.GetOnClick()() } self.context.HandleFocus(types.OnFocusOpts{}) return nil } func (self *ListController) pushContextIfNotFocused() error { if !self.isFocused() { self.c.Context().Push(self.context, types.OnFocusOpts{}) } return nil } func (self *ListController) isFocused() bool { return self.c.Context().Current().GetKey() == self.context.GetKey() } func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Handler: self.HandlePrevLine}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.HandlePrevLine}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Handler: self.HandleNextLine}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.HandleNextLine}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevPage), Handler: self.HandlePrevPage, Description: self.c.Tr.PrevPage}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextPage), Handler: self.HandleNextPage, Description: self.c.Tr.NextPage}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.GotoTop, Alternative: ""}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom, Alternative: ""}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), Handler: self.HandleGotoTop}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), Handler: self.HandleGotoBottom}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight}, } if self.context.RangeSelectEnabled() { bindings = append(bindings, []*types.Binding{ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ToggleRangeSelect), Handler: self.HandleToggleRangeSelect, Description: self.c.Tr.ToggleRangeSelect}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectDown), Handler: self.HandleRangeSelectDown, Description: self.c.Tr.RangeSelectDown}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectUp), Handler: self.HandleRangeSelectUp, Description: self.c.Tr.RangeSelectUp}, }..., ) } return bindings } func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.context.GetViewName(), Key: gocui.MouseWheelUp, Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollUp() }, }, { ViewName: self.context.GetViewName(), Key: gocui.MouseLeft, Handler: func(opts gocui.ViewMouseBindingOpts) error { return self.HandleClick(opts) }, }, { ViewName: self.context.GetViewName(), Key: gocui.MouseWheelDown, Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollDown() }, }, } } lazygit-0.50.0+ds1/pkg/gui/controllers/list_controller_trait.go000066400000000000000000000106071500612110400246170ustar00rootroot00000000000000package controllers import ( "errors" "github.com/jesseduffield/lazygit/pkg/gui/types" ) // Embed this into your list controller to get some convenience methods for // ensuring a single item is selected, etc. type ListControllerTrait[T comparable] struct { c *ControllerCommon context types.IListContext getSelectedItem func() T getSelectedItems func() ([]T, int, int) } func NewListControllerTrait[T comparable]( c *ControllerCommon, context types.IListContext, getSelected func() T, getSelectedItems func() ([]T, int, int), ) *ListControllerTrait[T] { return &ListControllerTrait[T]{ c: c, context: context, getSelectedItem: getSelected, getSelectedItems: getSelectedItems, } } // Convenience function for combining multiple disabledReason callbacks. // The first callback to return a disabled reason will be the one returned. func (self *ListControllerTrait[T]) require(callbacks ...func() *types.DisabledReason) func() *types.DisabledReason { return func() *types.DisabledReason { for _, callback := range callbacks { if disabledReason := callback(); disabledReason != nil { return disabledReason } } return nil } } // Convenience function for enforcing that a single item is selected. // Also takes callbacks for additional disabled reasons, and passes the selected // item into each one. func (self *ListControllerTrait[T]) singleItemSelected(callbacks ...func(T) *types.DisabledReason) func() *types.DisabledReason { return func() *types.DisabledReason { if self.context.GetList().AreMultipleItemsSelected() { return &types.DisabledReason{Text: self.c.Tr.RangeSelectNotSupported} } var zeroValue T item := self.getSelectedItem() if item == zeroValue { return &types.DisabledReason{Text: self.c.Tr.NoItemSelected} } for _, callback := range callbacks { if reason := callback(item); reason != nil { return reason } } return nil } } // Ensures that at least one item is selected. func (self *ListControllerTrait[T]) itemRangeSelected(callbacks ...func([]T, int, int) *types.DisabledReason) func() *types.DisabledReason { return func() *types.DisabledReason { items, startIdx, endIdx := self.getSelectedItems() if len(items) == 0 { return &types.DisabledReason{Text: self.c.Tr.NoItemSelected} } for _, callback := range callbacks { if reason := callback(items, startIdx, endIdx); reason != nil { return reason } } return nil } } func (self *ListControllerTrait[T]) itemsSelected(callbacks ...func([]T) *types.DisabledReason) func() *types.DisabledReason { //nolint:unused return func() *types.DisabledReason { items, _, _ := self.getSelectedItems() if len(items) == 0 { return &types.DisabledReason{Text: self.c.Tr.NoItemSelected} } for _, callback := range callbacks { if reason := callback(items); reason != nil { return reason } } return nil } } // Passes the selected item to the callback. Used for handler functions. func (self *ListControllerTrait[T]) withItem(callback func(T) error) func() error { return func() error { var zeroValue T commit := self.getSelectedItem() if commit == zeroValue { return errors.New(self.c.Tr.NoItemSelected) } return callback(commit) } } func (self *ListControllerTrait[T]) withItems(callback func([]T) error) func() error { return func() error { items, _, _ := self.getSelectedItems() if len(items) == 0 { return errors.New(self.c.Tr.NoItemSelected) } return callback(items) } } // like withItems but also passes the start and end index of the selection func (self *ListControllerTrait[T]) withItemsRange(callback func([]T, int, int) error) func() error { return func() error { items, startIdx, endIdx := self.getSelectedItems() if len(items) == 0 { return errors.New(self.c.Tr.NoItemSelected) } return callback(items, startIdx, endIdx) } } // Like withItem, but doesn't show an error message if no item is selected. // Use this for click actions (it's a no-op to click empty space) func (self *ListControllerTrait[T]) withItemGraceful(callback func(T) error) func() error { return func() error { var zeroValue T commit := self.getSelectedItem() if commit == zeroValue { return nil } return callback(commit) } } // All controllers must implement this method so we're defining it here for convenience func (self *ListControllerTrait[T]) Context() types.Context { return self.context } lazygit-0.50.0+ds1/pkg/gui/controllers/local_commits_controller.go000066400000000000000000001433141500612110400252700ustar00rootroot00000000000000package controllers import ( "strings" "github.com/go-errors/errors" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) // after selecting the 200th commit, we'll load in all the rest const COMMIT_THRESHOLD = 200 type ( PullFilesFn func() error ) type LocalCommitsController struct { baseController *ListControllerTrait[*models.Commit] c *ControllerCommon pullFiles PullFilesFn } var _ types.IController = &LocalCommitsController{} func NewLocalCommitsController( c *ControllerCommon, pullFiles PullFilesFn, ) *LocalCommitsController { return &LocalCommitsController{ baseController: baseController{}, c: c, pullFiles: pullFiles, ListControllerTrait: NewListControllerTrait( c, c.Contexts().LocalCommits, c.Contexts().LocalCommits.GetSelected, c.Contexts().LocalCommits.GetSelectedItems, ), } } func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { editCommitKey := opts.Config.Universal.Edit outsideFilterModeBindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Commits.SquashDown), Handler: self.withItemsRange(self.squashDown), GetDisabledReason: self.require( self.itemRangeSelected( self.midRebaseCommandEnabled, self.canSquashOrFixup, ), ), Description: self.c.Tr.Squash, Tooltip: self.c.Tr.SquashTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.MarkCommitAsFixup), Handler: self.withItemsRange(self.fixup), GetDisabledReason: self.require( self.itemRangeSelected( self.midRebaseCommandEnabled, self.canSquashOrFixup, ), ), Description: self.c.Tr.Fixup, Tooltip: self.c.Tr.FixupTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.RenameCommit), Handler: self.withItem(self.reword), GetDisabledReason: self.require( self.singleItemSelected(self.rewordEnabled), ), Description: self.c.Tr.Reword, Tooltip: self.c.Tr.CommitRewordTooltip, DisplayOnScreen: true, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Commits.RenameCommitWithEditor), Handler: self.withItem(self.rewordEditor), GetDisabledReason: self.require( self.singleItemSelected(self.rewordEnabled), ), Description: self.c.Tr.RewordCommitEditor, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItemsRange(self.drop), GetDisabledReason: self.require( self.itemRangeSelected( self.canDropCommits, ), ), Description: self.c.Tr.DropCommit, Tooltip: self.c.Tr.DropCommitTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(editCommitKey), Handler: self.withItemsRange(self.edit), GetDisabledReason: self.require( self.itemRangeSelected(self.midRebaseCommandEnabled), ), Description: self.c.Tr.EditCommit, ShortDescription: self.c.Tr.Edit, Tooltip: self.c.Tr.EditCommitTooltip, DisplayOnScreen: true, }, { // The user-facing description here is 'Start interactive rebase' but internally // we're calling it 'quick-start interactive rebase' to differentiate it from // when you manually select the base commit. Key: opts.GetKey(opts.Config.Commits.StartInteractiveRebase), Handler: self.quickStartInteractiveRebase, GetDisabledReason: self.require(self.notMidRebase(self.c.Tr.AlreadyRebasing), self.canFindCommitForQuickStart), Description: self.c.Tr.QuickStartInteractiveRebase, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.QuickStartInteractiveRebaseTooltip, map[string]string{ "editKey": keybindings.Label(editCommitKey), }), }, { Key: opts.GetKey(opts.Config.Commits.PickCommit), Handler: self.withItems(self.pick), GetDisabledReason: self.require( self.itemRangeSelected(self.pickEnabled), ), Description: self.c.Tr.Pick, Tooltip: self.c.Tr.PickCommitTooltip, // Not displaying this because we only want to display it when a TODO commit // is selected. A keybinding is displayed in the options view if Display is true, // and if it's not disabled, but if we disable it whenever a non-TODO commit is // selected, we'll be preventing pulls from happening within the commits view // (given they both use the 'p' key). Some approaches that come to mind: // * Allow a disabled keybinding to conditionally fallback to a global keybinding // * Allow a separate way of deciding whether a keybinding is displayed in the options view DisplayOnScreen: false, }, { Key: opts.GetKey(opts.Config.Commits.CreateFixupCommit), Handler: self.withItem(self.createFixupCommit), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreateFixupCommit, Tooltip: utils.ResolvePlaceholderString( self.c.Tr.CreateFixupCommitTooltip, map[string]string{ "squashAbove": keybindings.Label(opts.Config.Commits.SquashAboveCommits), }, ), }, { Key: opts.GetKey(opts.Config.Commits.SquashAboveCommits), Handler: self.squashFixupCommits, GetDisabledReason: self.require( self.notMidRebase(self.c.Tr.AlreadyRebasing), ), Description: self.c.Tr.SquashAboveCommits, Tooltip: self.c.Tr.SquashAboveCommitsTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Commits.MoveDownCommit), Handler: self.withItemsRange(self.moveDown), GetDisabledReason: self.require(self.itemRangeSelected( self.midRebaseMoveCommandEnabled, self.canMoveDown, )), Description: self.c.Tr.MoveDownCommit, }, { Key: opts.GetKey(opts.Config.Commits.MoveUpCommit), Handler: self.withItemsRange(self.moveUp), GetDisabledReason: self.require(self.itemRangeSelected( self.midRebaseMoveCommandEnabled, self.canMoveUp, )), Description: self.c.Tr.MoveUpCommit, }, { Key: opts.GetKey(opts.Config.Commits.PasteCommits), Handler: self.paste, GetDisabledReason: self.require(self.canPaste), Description: self.c.Tr.PasteCommits, DisplayStyle: &style.FgCyan, }, { Key: opts.GetKey(opts.Config.Commits.MarkCommitAsBaseForRebase), Handler: self.withItem(self.markAsBaseCommit), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.MarkAsBaseCommit, Tooltip: self.c.Tr.MarkAsBaseCommitTooltip, }, // overriding this navigation keybinding because we might need to load // more commits on demand { Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.gotoBottom, Description: self.c.Tr.GotoBottom, Tag: "navigation", }, } for _, binding := range outsideFilterModeBindings { binding.Handler = opts.Guards.OutsideFilterMode(binding.Handler) } bindings := append(outsideFilterModeBindings, []*types.Binding{ // overriding this navigation keybinding because we might need to load // more commits on demand { Key: opts.GetKey(opts.Config.Universal.StartSearch), Handler: self.openSearch, Description: self.c.Tr.StartSearch, Tag: "navigation", }, { Key: opts.GetKey(opts.Config.Commits.AmendToCommit), Handler: self.withItem(self.amendTo), GetDisabledReason: self.require(self.singleItemSelected(self.canAmend)), Description: self.c.Tr.Amend, Tooltip: self.c.Tr.AmendCommitTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.ResetCommitAuthor), Handler: self.withItemsRange(self.amendAttribute), GetDisabledReason: self.require(self.itemRangeSelected(self.canAmendRange)), Description: self.c.Tr.AmendCommitAttribute, Tooltip: self.c.Tr.AmendCommitAttributeTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Commits.RevertCommit), Handler: self.withItemsRange(self.revert), GetDisabledReason: self.require(self.itemRangeSelected()), Description: self.c.Tr.Revert, Tooltip: self.c.Tr.RevertCommitTooltip, }, { Key: opts.GetKey(opts.Config.Commits.CreateTag), Handler: self.withItem(self.createTag), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.TagCommit, Tooltip: self.c.Tr.TagCommitTooltip, }, { Key: opts.GetKey(opts.Config.Commits.OpenLogMenu), Handler: self.handleOpenLogMenu, Description: self.c.Tr.OpenLogMenu, Tooltip: self.c.Tr.OpenLogMenuTooltip, OpensMenu: true, }, }...) return bindings } func (self *LocalCommitsController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { var task types.UpdateTask commit := self.context().GetSelected() if commit == nil { task = types.NewRenderStringTask(self.c.Tr.NoCommitsThisBranch) } else if commit.Action == todo.UpdateRef { task = types.NewRenderStringTask( utils.ResolvePlaceholderString( self.c.Tr.UpdateRefHere, map[string]string{ "ref": strings.TrimPrefix(commit.Name, "refs/heads/"), })) } else if commit.Action == todo.Exec { task = types.NewRenderStringTask( self.c.Tr.ExecCommandHere + "\n\n" + commit.Name) } else { refRange := self.context().GetSelectedRefRangeForDiffFiles() task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Patch", SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: task, }, Secondary: secondaryPatchPanelUpdateOpts(self.c), }) }) } } func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts { if c.Git().Patch.PatchBuilder.Active() { patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false) return &types.ViewUpdateOpts{ Task: types.NewRenderStringWithoutScrollTask(patch), Title: c.Tr.CustomPatch, } } return nil } func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { if self.isRebasing() { return self.updateTodos(todo.Squash, selectedCommits) } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Squash, Prompt: self.c.Tr.SureSquashThisCommit, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) return self.interactiveRebase(todo.Squash, startIdx, endIdx) }) }, }) return nil } func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, startIdx int, endIdx int) error { if self.isRebasing() { return self.updateTodos(todo.Fixup, selectedCommits) } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Fixup, Prompt: self.c.Tr.SureFixupThisCommit, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) return self.interactiveRebase(todo.Fixup, startIdx, endIdx) }) }, }) return nil } func (self *LocalCommitsController) reword(commit *models.Commit) error { commitMessage, err := self.c.Git().Commit.GetCommitMessage(commit.Hash()) if err != nil { return err } if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage { commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth) } self.c.Helpers().Commits.OpenCommitMessagePanel( &helpers.OpenCommitMessagePanelOpts{ CommitIndex: self.context().GetSelectedLineIdx(), InitialMessage: commitMessage, SummaryTitle: self.c.Tr.Actions.RewordCommit, DescriptionTitle: self.c.Tr.CommitDescriptionTitle, PreserveMessage: false, OnConfirm: self.handleReword, OnSwitchToEditor: self.switchFromCommitMessagePanelToEditor, }, ) return nil } func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepath string) error { if self.isSelectedHeadCommit() { return self.c.RunSubprocessAndRefresh( self.c.Git().Commit.RewordLastCommitInEditorWithMessageFileCmdObj(filepath)) } err := self.c.Git().Rebase.BeginInteractiveRebaseForCommit(self.c.Model().Commits, self.context().GetSelectedLineIdx(), false) if err != nil { return err } // now the selected commit should be our head so we'll amend it with the new message err = self.c.RunSubprocessAndRefresh( self.c.Git().Commit.RewordLastCommitInEditorWithMessageFileCmdObj(filepath)) if err != nil { return err } err = self.c.Git().Rebase.ContinueRebase() if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) } func (self *LocalCommitsController) handleReword(summary string, description string) error { if models.IsHeadCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx()) { // we've selected the top commit so no rebase is required return self.c.Helpers().GPG.WithGpgHandling(self.c.Git().Commit.RewordLastCommit(summary, description), git_commands.CommitGpgSign, self.c.Tr.RewordingStatus, nil, nil) } return self.c.WithWaitingStatus(self.c.Tr.RewordingStatus, func(gocui.Task) error { err := self.c.Git().Rebase.RewordCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), summary, description) if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }) } func (self *LocalCommitsController) doRewordEditor() error { self.c.LogAction(self.c.Tr.Actions.RewordCommit) if self.isSelectedHeadCommit() { return self.c.RunSubprocessAndRefresh(self.c.Git().Commit.RewordLastCommitInEditorCmdObj()) } subProcess, err := self.c.Git().Rebase.RewordCommitInEditor( self.c.Model().Commits, self.context().GetSelectedLineIdx(), ) if err != nil { return err } if subProcess != nil { return self.c.RunSubprocessAndRefresh(subProcess) } return nil } func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error { if self.c.UserConfig().Gui.SkipRewordInEditorWarning { return self.doRewordEditor() } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.RewordInEditorTitle, Prompt: self.c.Tr.RewordInEditorPrompt, HandleConfirm: self.doRewordEditor, }) return nil } func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, startIdx int, endIdx int) error { if self.isRebasing() { groupedTodos := lo.GroupBy(selectedCommits, func(c *models.Commit) bool { return c.Action == todo.UpdateRef }) updateRefTodos := groupedTodos[true] nonUpdateRefTodos := groupedTodos[false] if len(updateRefTodos) > 0 { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.DropCommitTitle, Prompt: self.c.Tr.DropUpdateRefPrompt, HandleConfirm: func() error { selectedIdx, rangeStartIdx, rangeSelectMode := self.context().GetSelectionRangeAndMode() if err := self.c.Git().Rebase.DeleteUpdateRefTodos(updateRefTodos); err != nil { return err } if selectedIdx > rangeStartIdx { selectedIdx = max(selectedIdx-len(updateRefTodos), rangeStartIdx) } else { rangeStartIdx = max(rangeStartIdx-len(updateRefTodos), selectedIdx) } self.context().SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, rangeSelectMode) return self.updateTodos(todo.Drop, nonUpdateRefTodos) }, }) return nil } return self.updateTodos(todo.Drop, selectedCommits) } isMerge := selectedCommits[0].IsMerge() self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.DropCommitTitle, Prompt: lo.Ternary(isMerge, self.c.Tr.DropMergeCommitPrompt, self.c.Tr.DropCommitPrompt), HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) if isMerge { return self.dropMergeCommit(startIdx) } return self.interactiveRebase(todo.Drop, startIdx, endIdx) }) }, }) return nil } func (self *LocalCommitsController) dropMergeCommit(commitIdx int) error { err := self.c.Git().Rebase.DropMergeCommit(self.c.Model().Commits, commitIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, startIdx int, endIdx int) error { if self.isRebasing() { return self.updateTodos(todo.Edit, selectedCommits) } commits := self.c.Model().Commits if !commits[endIdx].IsMerge() { selectionRangeAndMode := self.getSelectionRangeAndMode() err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{ Mode: types.BLOCK_UI, Then: func() error { self.restoreSelectionRangeAndMode(selectionRangeAndMode) return nil }, }) } return self.startInteractiveRebaseWithEdit(selectedCommits) } func (self *LocalCommitsController) quickStartInteractiveRebase() error { commitToEdit, err := self.findCommitForQuickStartInteractiveRebase() if err != nil { return err } return self.startInteractiveRebaseWithEdit([]*models.Commit{commitToEdit}) } func (self *LocalCommitsController) startInteractiveRebaseWithEdit( commitsToEdit []*models.Commit, ) error { return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.EditCommit) selectionRangeAndMode := self.getSelectionRangeAndMode() err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() error { todos := make([]*models.Commit, 0, len(commitsToEdit)-1) for _, c := range commitsToEdit[:len(commitsToEdit)-1] { // Merge commits can't be set to "edit", so just skip them if !c.IsMerge() { todos = append(todos, models.NewCommit(self.c.Model().HashPool, models.NewCommitOpts{Hash: c.Hash(), Action: todo.Pick})) } } if len(todos) > 0 { err := self.updateTodos(todo.Edit, todos) if err != nil { return err } } self.restoreSelectionRangeAndMode(selectionRangeAndMode) return nil }}) }) } type SelectionRangeAndMode struct { selectedHash string rangeStartHash string mode traits.RangeSelectMode } func (self *LocalCommitsController) getSelectionRangeAndMode() SelectionRangeAndMode { selectedIdx, rangeStartIdx, rangeSelectMode := self.context().GetSelectionRangeAndMode() commits := self.c.Model().Commits selectedHash := commits[selectedIdx].Hash() rangeStartHash := commits[rangeStartIdx].Hash() return SelectionRangeAndMode{selectedHash, rangeStartHash, rangeSelectMode} } func (self *LocalCommitsController) restoreSelectionRangeAndMode(selectionRangeAndMode SelectionRangeAndMode) { // We need to select the same commit range again because after starting a rebase, // new lines can be added for update-ref commands in the TODO file, due to // stacked branches. So the selected commits may be in different positions in the list. _, newSelectedIdx, ok1 := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool { return c.Hash() == selectionRangeAndMode.selectedHash }) _, newRangeStartIdx, ok2 := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool { return c.Hash() == selectionRangeAndMode.rangeStartHash }) if ok1 && ok2 { self.context().SetSelectionRangeAndMode(newSelectedIdx, newRangeStartIdx, selectionRangeAndMode.mode) self.context().HandleFocus(types.OnFocusOpts{}) } } func (self *LocalCommitsController) findCommitForQuickStartInteractiveRebase() (*models.Commit, error) { commit, index, ok := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool { return c.IsMerge() || c.Status == models.StatusMerged }) if !ok || index == 0 { errorMsg := utils.ResolvePlaceholderString(self.c.Tr.CannotQuickStartInteractiveRebase, map[string]string{ "editKey": keybindings.Label(self.c.UserConfig().Keybinding.Universal.Edit), }) return nil, errors.New(errorMsg) } return commit, nil } func (self *LocalCommitsController) pick(selectedCommits []*models.Commit) error { if self.isRebasing() { return self.updateTodos(todo.Pick, selectedCommits) } // at this point we aren't actually rebasing so we will interpret this as an // attempt to pull. We might revoke this later after enabling configurable keybindings return self.pullFiles() } func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand, startIdx int, endIdx int) error { // When performing an action that will remove the selected commits, we need to select the // next commit down (which will end up at the start index after the action is performed) if action == todo.Drop || action == todo.Fixup || action == todo.Squash { self.context().SetSelection(startIdx) } err := self.c.Git().Rebase.InteractiveRebase(self.c.Model().Commits, startIdx, endIdx, action) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } // updateTodos sees if the selected commit is in fact a rebasing // commit meaning you are trying to edit the todo file rather than actually // begin a rebase. It then updates the todo file with that action func (self *LocalCommitsController) updateTodos(action todo.TodoCommand, selectedCommits []*models.Commit) error { if err := self.c.Git().Rebase.EditRebaseTodo(selectedCommits, action); err != nil { return err } return self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, }) } func (self *LocalCommitsController) rewordEnabled(commit *models.Commit) *types.DisabledReason { // for now we do not support setting 'reword' on TODO commits because it requires an editor // and that means we either unconditionally wait around for the subprocess to ask for // our input or we set a lazygit client as the EDITOR env variable and have it // request us to edit the commit message when prompted. if commit.IsTODO() { return &types.DisabledReason{Text: self.c.Tr.RewordNotSupported} } // If we are in a rebase, the only action that is allowed for // non-todo commits is rewording the current head commit if self.isRebasing() && !self.isSelectedHeadCommit() { return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing} } return nil } func (self *LocalCommitsController) isRebasing() bool { return self.c.Model().WorkingTreeStateAtLastCommitRefresh.Any() } func (self *LocalCommitsController) isCherryPickingOrReverting() bool { return self.c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking || self.c.Model().WorkingTreeStateAtLastCommitRefresh.Reverting } func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { if self.isRebasing() { if err := self.c.Git().Rebase.MoveTodosDown(selectedCommits); err != nil { return err } self.context().MoveSelection(1) return self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, }) } return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) err := self.c.Git().Rebase.MoveCommitsDown(self.c.Model().Commits, startIdx, endIdx) if err == nil { self.context().MoveSelection(1) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{Mode: types.SYNC}) }) } func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { if self.isRebasing() { if err := self.c.Git().Rebase.MoveTodosUp(selectedCommits); err != nil { return err } self.context().MoveSelection(-1) return self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, }) } return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) err := self.c.Git().Rebase.MoveCommitsUp(self.c.Model().Commits, startIdx, endIdx) if err == nil { self.context().MoveSelection(-1) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{Mode: types.SYNC}) }) } func (self *LocalCommitsController) amendTo(commit *models.Commit) error { if self.isSelectedHeadCommit() { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.AmendCommitTitle, Prompt: self.c.Tr.AmendCommitPrompt, HandleConfirm: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { if err := self.c.Helpers().AmendHelper.AmendHead(); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }) }, }) return nil } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.AmendCommitTitle, Prompt: self.c.Tr.AmendCommitPrompt, HandleConfirm: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) err := self.c.Git().Rebase.AmendTo(self.c.Model().Commits, self.context().GetView().SelectedLineIdx()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) }) }, }) return nil } func (self *LocalCommitsController) canAmendRange(commits []*models.Commit, start, end int) *types.DisabledReason { if (start != end || !self.isHeadCommit(start)) && self.isRebasing() { return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing} } return nil } func (self *LocalCommitsController) canAmend(_ *models.Commit) *types.DisabledReason { idx := self.context().GetSelectedLineIdx() return self.canAmendRange(self.c.Model().Commits, idx, idx) } func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, start, end int) error { opts := self.c.KeybindingsOpts() return self.c.Menu(types.CreateMenuOptions{ Title: "Amend commit attribute", Items: []*types.MenuItem{ { Label: self.c.Tr.ResetAuthor, OnPress: func() error { return self.resetAuthor(start, end) }, Key: opts.GetKey(opts.Config.AmendAttribute.ResetAuthor), Tooltip: self.c.Tr.ResetAuthorTooltip, }, { Label: self.c.Tr.SetAuthor, OnPress: func() error { return self.setAuthor(start, end) }, Key: opts.GetKey(opts.Config.AmendAttribute.SetAuthor), Tooltip: self.c.Tr.SetAuthorTooltip, }, { Label: self.c.Tr.AddCoAuthor, OnPress: func() error { return self.addCoAuthor(start, end) }, Key: opts.GetKey(opts.Config.AmendAttribute.AddCoAuthor), Tooltip: self.c.Tr.AddCoAuthorTooltip, }, }, }) } func (self *LocalCommitsController) resetAuthor(start, end int) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) if err := self.c.Git().Rebase.ResetCommitAuthor(self.c.Model().Commits, start, end); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }) } func (self *LocalCommitsController) setAuthor(start, end int) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.SetAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) if err := self.c.Git().Rebase.SetCommitAuthor(self.c.Model().Commits, start, end, value); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }) }, }) return nil } func (self *LocalCommitsController) addCoAuthor(start, end int) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) if err := self.c.Git().Rebase.AddCommitCoAuthor(self.c.Model().Commits, start, end, value); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }) }, }) return nil } func (self *LocalCommitsController) revert(commits []*models.Commit, start, end int) error { var promptText string if len(commits) == 1 { promptText = utils.ResolvePlaceholderString( self.c.Tr.ConfirmRevertCommit, map[string]string{ "selectedCommit": commits[0].ShortHash(), }) } else { promptText = self.c.Tr.ConfirmRevertCommitRange } hashes := lo.Map(commits, func(c *models.Commit, _ int) string { return c.Hash() }) isMerge := lo.SomeBy(commits, func(c *models.Commit) bool { return c.IsMerge() }) self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Actions.RevertCommit, Prompt: promptText, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RevertCommit) return self.c.WithWaitingStatusSync(self.c.Tr.RevertingStatus, func() error { result := self.c.Git().Commit.Revert(hashes, isMerge) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(result); err != nil { return err } self.context().MoveSelection(len(commits)) return self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.BRANCHES}, }) }) }, }) return nil } func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) error { var disabledReasonWhenFilesAreNeeded *types.DisabledReason if len(self.c.Model().Files) == 0 { disabledReasonWhenFilesAreNeeded = &types.DisabledReason{ Text: self.c.Tr.NoFilesStagedTitle, ShowErrorInPanel: true, } } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.CreateFixupCommit, Items: []*types.MenuItem{ { Label: self.c.Tr.FixupMenu_Fixup, Key: 'f', OnPress: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit) return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { return err } if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil { return err } self.context().MoveSelectedLine(1) return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) }) }) }, DisabledReason: disabledReasonWhenFilesAreNeeded, Tooltip: self.c.Tr.FixupMenu_FixupTooltip, }, { Label: self.c.Tr.FixupMenu_AmendWithChanges, Key: 'a', OnPress: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { return self.createAmendCommit(commit, true) }) }, DisabledReason: disabledReasonWhenFilesAreNeeded, Tooltip: self.c.Tr.FixupMenu_AmendWithChangesTooltip, }, { Label: self.c.Tr.FixupMenu_AmendWithoutChanges, Key: 'r', OnPress: func() error { return self.createAmendCommit(commit, false) }, Tooltip: self.c.Tr.FixupMenu_AmendWithoutChangesTooltip, }, }, }) } func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCommit *models.Commit) error { if self.c.Git().Version.IsOlderThan(2, 38, 0) { // Git 2.38.0 introduced the `rebase.updateRefs` config option. Don't // move the commit down with older versions, as it would break the stack. return nil } if self.c.Git().Status.WorkingTreeState().Any() { // Can't move commits while rebasing return nil } if targetCommit.Status == models.StatusMerged { // Target commit is already on main. It's a bit questionable that we // allow creating a fixup commit for it in the first place, but we // always did, so why restrict that now; however, it doesn't make sense // to move the created fixup commit down in that case. return nil } if !self.c.Git().Config.GetRebaseUpdateRefs() { // If the user has disabled rebase.updateRefs, we don't move the fixup // because this would break the stack of branches (presumably they like // to manage it themselves manually, or something). return nil } headOfOwnerBranchIdx := -1 for i := self.context().GetSelectedLineIdx(); i > 0; i-- { if lo.SomeBy(self.c.Model().Branches, func(b *models.Branch) bool { return b.CommitHash == self.c.Model().Commits[i].Hash() }) { headOfOwnerBranchIdx = i break } } if headOfOwnerBranchIdx == -1 { return nil } return self.c.Git().Rebase.MoveFixupCommitDown(self.c.Model().Commits, headOfOwnerBranchIdx) } func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, includeFileChanges bool) error { commitMessage, err := self.c.Git().Commit.GetCommitMessage(commit.Hash()) if err != nil { return err } if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage { commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth) } originalSubject, _, _ := strings.Cut(commitMessage, "\n") self.c.Helpers().Commits.OpenCommitMessagePanel( &helpers.OpenCommitMessagePanelOpts{ CommitIndex: self.context().GetSelectedLineIdx(), InitialMessage: commitMessage, SummaryTitle: self.c.Tr.CreateAmendCommit, DescriptionTitle: self.c.Tr.CommitDescriptionTitle, PreserveMessage: false, OnConfirm: func(summary string, description string) error { self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit) return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { return err } if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil { return err } self.context().MoveSelectedLine(1) return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) }) }, OnSwitchToEditor: nil, }, ) return nil } func (self *LocalCommitsController) squashFixupCommits() error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.SquashAboveCommits, Items: []*types.MenuItem{ { Label: self.c.Tr.SquashCommitsInCurrentBranch, OnPress: self.squashAllFixupsInCurrentBranch, DisabledReason: self.canFindCommitForSquashFixupsInCurrentBranch(), Key: 'b', Tooltip: self.c.Tr.SquashCommitsInCurrentBranchTooltip, }, { Label: self.c.Tr.SquashCommitsAboveSelectedCommit, OnPress: self.withItem(self.squashAllFixupsAboveSelectedCommit), DisabledReason: self.singleItemSelected()(), Key: 'a', Tooltip: self.c.Tr.SquashCommitsAboveSelectedTooltip, }, }, }) } func (self *LocalCommitsController) squashAllFixupsAboveSelectedCommit(commit *models.Commit) error { return self.squashFixupsImpl(commit, self.context().GetSelectedLineIdx()) } func (self *LocalCommitsController) squashAllFixupsInCurrentBranch() error { commit, rebaseStartIdx, err := self.findCommitForSquashFixupsInCurrentBranch() if err != nil { return err } return self.squashFixupsImpl(commit, rebaseStartIdx) } func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, rebaseStartIdx int) error { selectionOffset := countSquashableCommitsAbove(self.c.Model().Commits, self.context().GetSelectedLineIdx(), rebaseStartIdx) return self.c.WithWaitingStatusSync(self.c.Tr.SquashingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) self.context().MoveSelectedLine(-selectionOffset) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{Mode: types.SYNC}) }) } func (self *LocalCommitsController) findCommitForSquashFixupsInCurrentBranch() (*models.Commit, int, error) { commits := self.c.Model().Commits _, index, ok := lo.FindIndexOf(commits, func(c *models.Commit) bool { return c.IsMerge() || c.Status == models.StatusMerged }) if !ok || index == 0 { return nil, -1, errors.New(self.c.Tr.CannotSquashCommitsInCurrentBranch) } return commits[index-1], index - 1, nil } // Anticipate how many commits above the selectedIdx are going to get squashed // by the SquashAllAboveFixupCommits call, so that we can adjust the selection // afterwards. Let's hope we're matching git's behavior correctly here. func countSquashableCommitsAbove(commits []*models.Commit, selectedIdx int, rebaseStartIdx int) int { result := 0 // For each commit _above_ the selection, ... for i, commit := range commits[0:selectedIdx] { // ... see if it is a fixup commit, and get the base subject it applies to if baseSubject, isFixup := isFixupCommit(commit.Name); isFixup { // Then, for each commit after the fixup, up to and including the // rebase start commit, see if we find the base commit for _, baseCommit := range commits[i+1 : rebaseStartIdx+1] { if strings.HasPrefix(baseCommit.Name, baseSubject) { result++ } } } } return result } // Check whether the given subject line is the subject of a fixup commit, and // returns (trimmedSubject, true) if so (where trimmedSubject is the subject // with all fixup prefixes removed), or (subject, false) if not. func isFixupCommit(subject string) (string, bool) { prefixes := []string{"fixup! ", "squash! ", "amend! "} trimPrefix := func(s string) (string, bool) { for _, prefix := range prefixes { if strings.HasPrefix(s, prefix) { return strings.TrimPrefix(s, prefix), true } } return s, false } if subject, wasTrimmed := trimPrefix(subject); wasTrimmed { for { // handle repeated prefixes like "fixup! amend! fixup! Subject" if subject, wasTrimmed = trimPrefix(subject); !wasTrimmed { break } } return subject, true } return subject, false } func (self *LocalCommitsController) createTag(commit *models.Commit) error { return self.c.Helpers().Tags.OpenCreateTagPrompt(commit.Hash(), func() {}) } func (self *LocalCommitsController) openSearch() error { // we usually lazyload these commits but now that we're searching we need to load them now if self.context().GetLimitCommits() { self.context().SetLimitCommits(false) if err := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}}); err != nil { return err } } return self.c.Helpers().Search.OpenSearchPrompt(self.context()) } func (self *LocalCommitsController) gotoBottom() error { // we usually lazyload these commits but now that we're jumping to the bottom we need to load them now if self.context().GetLimitCommits() { self.context().SetLimitCommits(false) if err := self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}}); err != nil { return err } } self.context().SetSelectedLineIdx(self.context().Len() - 1) return nil } func (self *LocalCommitsController) handleOpenLogMenu() error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.LogMenuTitle, Items: []*types.MenuItem{ { Label: self.c.Tr.ToggleShowGitGraphAll, OnPress: func() error { self.context().SetShowWholeGitGraph(!self.context().GetShowWholeGitGraph()) if self.context().GetShowWholeGitGraph() { self.context().SetLimitCommits(false) } return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error { return self.c.Refresh( types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}}, ) }) }, }, { Label: self.c.Tr.ShowGitGraph, OpensMenu: true, OnPress: func() error { currentValue := self.c.GetAppState().GitLogShowGraph onPress := func(value string) func() error { return func() error { self.c.GetAppState().GitLogShowGraph = value self.c.SaveAppStateAndLogError() self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) self.c.PostRefreshUpdate(self.c.Contexts().SubCommits) return nil } } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.LogMenuTitle, Items: []*types.MenuItem{ { Label: "always", OnPress: onPress("always"), Widget: types.MakeMenuRadioButton(currentValue == "always"), }, { Label: "never", OnPress: onPress("never"), Widget: types.MakeMenuRadioButton(currentValue == "never"), }, { Label: "when maximised", OnPress: onPress("when-maximised"), Widget: types.MakeMenuRadioButton(currentValue == "when-maximised"), }, }, }) }, }, { Label: self.c.Tr.SortCommits, OpensMenu: true, OnPress: func() error { currentValue := self.c.GetAppState().GitLogOrder onPress := func(value string) func() error { return func() error { self.c.GetAppState().GitLogOrder = value self.c.SaveAppStateAndLogError() return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error { return self.c.Refresh( types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}, }, ) }) } } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.LogMenuTitle, Items: []*types.MenuItem{ { Label: "topological (topo-order)", OnPress: onPress("topo-order"), Widget: types.MakeMenuRadioButton(currentValue == "topo-order"), }, { Label: "date-order", OnPress: onPress("date-order"), Widget: types.MakeMenuRadioButton(currentValue == "date-order"), }, { Label: "author-date-order", OnPress: onPress("author-date-order"), Widget: types.MakeMenuRadioButton(currentValue == "author-date-order"), }, { Label: "default", OnPress: onPress("default"), Widget: types.MakeMenuRadioButton(currentValue == "default"), }, }, }) }, }, }, }) } func (self *LocalCommitsController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { context := self.context() if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() { context.SetLimitCommits(false) self.c.OnWorker(func(_ gocui.Task) error { return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}) }) } } } func (self *LocalCommitsController) context() *context.LocalCommitsContext { return self.c.Contexts().LocalCommits } func (self *LocalCommitsController) paste() error { return self.c.Helpers().CherryPick.Paste() } func (self *LocalCommitsController) canPaste() *types.DisabledReason { if !self.c.Helpers().CherryPick.CanPaste() { return &types.DisabledReason{Text: self.c.Tr.NoCopiedCommits} } return nil } func (self *LocalCommitsController) markAsBaseCommit(commit *models.Commit) error { if commit.Hash() == self.c.Modes().MarkedBaseCommit.GetHash() { // Reset when invoking it again on the marked commit self.c.Modes().MarkedBaseCommit.SetHash("") } else { self.c.Modes().MarkedBaseCommit.SetHash(commit.Hash()) } self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) return nil } func (self *LocalCommitsController) isHeadCommit(idx int) bool { return models.IsHeadCommit(self.c.Model().Commits, idx) } func (self *LocalCommitsController) isSelectedHeadCommit() bool { return self.isHeadCommit(self.context().GetSelectedLineIdx()) } func (self *LocalCommitsController) notMidRebase(message string) func() *types.DisabledReason { return func() *types.DisabledReason { if self.isRebasing() { return &types.DisabledReason{Text: message} } return nil } } func (self *LocalCommitsController) canFindCommitForQuickStart() *types.DisabledReason { if _, err := self.findCommitForQuickStartInteractiveRebase(); err != nil { return &types.DisabledReason{Text: err.Error(), ShowErrorInPanel: true} } return nil } func (self *LocalCommitsController) canFindCommitForSquashFixupsInCurrentBranch() *types.DisabledReason { if _, _, err := self.findCommitForSquashFixupsInCurrentBranch(); err != nil { return &types.DisabledReason{Text: err.Error()} } return nil } func (self *LocalCommitsController) canSquashOrFixup(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { if endIdx >= len(self.c.Model().Commits)-1 { return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit} } if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) { return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupMergeCommit} } return nil } func (self *LocalCommitsController) canMoveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { if endIdx >= len(self.c.Model().Commits)-1 { return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther} } if self.isRebasing() { commits := self.c.Model().Commits if !commits[endIdx+1].IsTODO() || commits[endIdx+1].Status == models.StatusConflicted { return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther} } } return nil } func (self *LocalCommitsController) canMoveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { if startIdx == 0 { return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther} } if self.isRebasing() { commits := self.c.Model().Commits if !commits[startIdx-1].IsTODO() || commits[startIdx-1].Status == models.StatusConflicted { return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther} } } return nil } // Ensures that if we are mid-rebase, we're only selecting valid commits (non-conflict TODO commits) func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { if self.isCherryPickingOrReverting() { return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} } if !self.isRebasing() { return nil } for _, commit := range selectedCommits { if !commit.IsTODO() { return &types.DisabledReason{Text: self.c.Tr.MustSelectTodoCommits} } if !isChangeOfRebaseTodoAllowed(commit.Action) { return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed} } } return nil } // Ensures that if we are mid-rebase, we're only selecting commits that can be moved func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { if self.isCherryPickingOrReverting() { return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} } if !self.isRebasing() { if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) { return &types.DisabledReason{Text: self.c.Tr.CannotMoveMergeCommit} } return nil } for _, commit := range selectedCommits { if !commit.IsTODO() { return &types.DisabledReason{Text: self.c.Tr.MustSelectTodoCommits} } // All todo types that can be edited are allowed to be moved, plus // update-ref todos if !isChangeOfRebaseTodoAllowed(commit.Action) && commit.Action != todo.UpdateRef { return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed} } } return nil } func (self *LocalCommitsController) canDropCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { if self.isCherryPickingOrReverting() { return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} } if !self.isRebasing() { if len(selectedCommits) > 1 && lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) { return &types.DisabledReason{Text: self.c.Tr.DroppingMergeRequiresSingleSelection} } return nil } nonUpdateRefTodos := lo.Filter(selectedCommits, func(c *models.Commit, _ int) bool { return c.Action != todo.UpdateRef }) for _, commit := range nonUpdateRefTodos { if !commit.IsTODO() { return &types.DisabledReason{Text: self.c.Tr.MustSelectTodoCommits} } if !isChangeOfRebaseTodoAllowed(commit.Action) { return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed} } } return nil } // These actions represent standard things you might want to do with a commit, // as opposed to TODO actions like 'merge', 'update-ref', etc. var standardActions = []todo.TodoCommand{ todo.Pick, todo.Drop, todo.Edit, todo.Fixup, todo.Squash, todo.Reword, } func isChangeOfRebaseTodoAllowed(oldAction todo.TodoCommand) bool { // Only allow updating a standard action, meaning we disallow // updating a merge commit or update ref commit (until we decide what would be sensible // to do in those cases) return lo.Contains(standardActions, oldAction) } func (self *LocalCommitsController) pickEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { if self.isCherryPickingOrReverting() { return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} } if !self.isRebasing() { // if not rebasing, we're going to do a pull so we don't care about the selection return nil } return self.midRebaseCommandEnabled(selectedCommits, startIdx, endIdx) } lazygit-0.50.0+ds1/pkg/gui/controllers/local_commits_controller_test.go000066400000000000000000000060701500612110400263240ustar00rootroot00000000000000package controllers import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/stretchr/testify/assert" ) func Test_countSquashableCommitsAbove(t *testing.T) { scenarios := []struct { name string commits []*models.Commit selectedIdx int rebaseStartIdx int expectedResult int }{ { name: "no squashable commits", commits: []*models.Commit{ {Name: "abc"}, {Name: "def"}, {Name: "ghi"}, }, selectedIdx: 2, rebaseStartIdx: 2, expectedResult: 0, }, { name: "some squashable commits, including for the selected commit", commits: []*models.Commit{ {Name: "fixup! def"}, {Name: "fixup! ghi"}, {Name: "abc"}, {Name: "def"}, {Name: "ghi"}, }, selectedIdx: 4, rebaseStartIdx: 4, expectedResult: 2, }, { name: "base commit is below rebase start", commits: []*models.Commit{ {Name: "fixup! def"}, {Name: "abc"}, {Name: "def"}, }, selectedIdx: 1, rebaseStartIdx: 1, expectedResult: 0, }, { name: "base commit does not exist at all", commits: []*models.Commit{ {Name: "fixup! xyz"}, {Name: "abc"}, {Name: "def"}, }, selectedIdx: 2, rebaseStartIdx: 2, expectedResult: 0, }, { name: "selected commit is in the middle of fixups", commits: []*models.Commit{ {Name: "fixup! def"}, {Name: "abc"}, {Name: "fixup! ghi"}, {Name: "def"}, {Name: "ghi"}, }, selectedIdx: 1, rebaseStartIdx: 4, expectedResult: 1, }, { name: "selected commit is after rebase start", commits: []*models.Commit{ {Name: "fixup! def"}, {Name: "abc"}, {Name: "def"}, {Name: "ghi"}, }, selectedIdx: 3, rebaseStartIdx: 2, expectedResult: 1, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { assert.Equal(t, s.expectedResult, countSquashableCommitsAbove(s.commits, s.selectedIdx, s.rebaseStartIdx)) }) } } func Test_isFixupCommit(t *testing.T) { scenarios := []struct { subject string expectedTrimmedSubject string expectedIsFixup bool }{ { subject: "Bla", expectedTrimmedSubject: "Bla", expectedIsFixup: false, }, { subject: "fixup Bla", expectedTrimmedSubject: "fixup Bla", expectedIsFixup: false, }, { subject: "fixup! Bla", expectedTrimmedSubject: "Bla", expectedIsFixup: true, }, { subject: "fixup! fixup! Bla", expectedTrimmedSubject: "Bla", expectedIsFixup: true, }, { subject: "amend! squash! Bla", expectedTrimmedSubject: "Bla", expectedIsFixup: true, }, { subject: "fixup!", expectedTrimmedSubject: "fixup!", expectedIsFixup: false, }, } for _, s := range scenarios { t.Run(s.subject, func(t *testing.T) { trimmedSubject, isFixupCommit := isFixupCommit(s.subject) assert.Equal(t, s.expectedTrimmedSubject, trimmedSubject) assert.Equal(t, s.expectedIsFixup, isFixupCommit) }) } } lazygit-0.50.0+ds1/pkg/gui/controllers/main_view_controller.go000066400000000000000000000061371500612110400244220ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type MainViewController struct { baseController c *ControllerCommon context *context.MainContext otherContext *context.MainContext } var _ types.IController = &MainViewController{} func NewMainViewController( c *ControllerCommon, context *context.MainContext, otherContext *context.MainContext, ) *MainViewController { return &MainViewController{ baseController: baseController{}, c: c, context: context, otherContext: otherContext, } } func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.TogglePanel), Handler: self.togglePanel, Description: self.c.Tr.ToggleStagingView, Tooltip: self.c.Tr.ToggleStagingViewTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: self.escape, Description: self.c.Tr.ExitFocusedMainView, }, { // overriding this because we want to read all of the task's output before we start searching Key: opts.GetKey(opts.Config.Universal.StartSearch), Handler: self.openSearch, Description: self.c.Tr.StartSearch, Tag: "navigation", }, } } func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.context.GetViewName(), Key: gocui.MouseLeft, Handler: func(opts gocui.ViewMouseBindingOpts) error { if self.isFocused() { return self.onClick(opts) } self.context.SetParentContext(self.otherContext.GetParentContext()) self.c.Context().Push(self.context, types.OnFocusOpts{ ClickedWindowName: self.context.GetWindowName(), ClickedViewLineIdx: opts.Y, }) return nil }, }, } } func (self *MainViewController) Context() types.Context { return self.context } func (self *MainViewController) togglePanel() error { if self.otherContext.GetView().Visible { self.otherContext.SetParentContext(self.context.GetParentContext()) self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) } return nil } func (self *MainViewController) escape() error { self.c.Context().Pop() return nil } func (self *MainViewController) onClick(opts gocui.ViewMouseBindingOpts) error { parentCtx := self.context.GetParentContext() if parentCtx.GetOnClickFocusedMainView() != nil { return parentCtx.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y) } return nil } func (self *MainViewController) openSearch() error { if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { manager.ReadToEnd(func() { self.c.OnUIThread(func() error { return self.c.Helpers().Search.OpenSearchPrompt(self.context) }) }) } return nil } func (self *MainViewController) isFocused() bool { return self.c.Context().Current().GetKey() == self.context.GetKey() } lazygit-0.50.0+ds1/pkg/gui/controllers/menu_controller.go000066400000000000000000000043131500612110400234020ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type MenuController struct { baseController *ListControllerTrait[*types.MenuItem] c *ControllerCommon } var _ types.IController = &MenuController{} func NewMenuController( c *ControllerCommon, ) *MenuController { return &MenuController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Menu, c.Contexts().Menu.GetSelected, c.Contexts().Menu.GetSelectedItems, ), c: c, } } // NOTE: if you add a new keybinding here, you'll also need to add it to // `reservedKeys` in `pkg/gui/context/menu_context.go` func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItem(self.press), GetDisabledReason: self.require(self.singleItemSelected()), }, { Key: opts.GetKey(opts.Config.Universal.Confirm), Handler: self.withItem(self.press), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Execute, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: self.close, Description: self.c.Tr.Close, DisplayOnScreen: true, }, } return bindings } func (self *MenuController) GetOnClick() func() error { return self.withItemGraceful(self.press) } func (self *MenuController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { selectedMenuItem := self.context().GetSelected() if selectedMenuItem != nil { self.c.Views().Tooltip.SetContent(self.c.Helpers().Confirmation.TooltipForMenuItem(selectedMenuItem)) } } } func (self *MenuController) press(selectedItem *types.MenuItem) error { return self.context().OnMenuPress(selectedItem) } func (self *MenuController) close() error { if self.context().IsFiltering() { self.c.Helpers().Search.Cancel() return nil } self.c.Context().Pop() return nil } func (self *MenuController) context() *context.MenuContext { return self.c.Contexts().Menu } lazygit-0.50.0+ds1/pkg/gui/controllers/merge_conflicts_controller.go000066400000000000000000000224031500612110400256010ustar00rootroot00000000000000package controllers import ( "os" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type MergeConflictsController struct { baseController c *ControllerCommon } var _ types.IController = &MergeConflictsController{} func NewMergeConflictsController( c *ControllerCommon, ) *MergeConflictsController { return &MergeConflictsController{ baseController: baseController{}, c: c, } } func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withRenderAndFocus(self.HandlePickHunk), Description: self.c.Tr.PickHunk, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Main.PickBothHunks), Handler: self.withRenderAndFocus(self.HandlePickAllHunks), Description: self.c.Tr.PickAllHunks, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.withRenderAndFocus(self.PrevConflictHunk), Description: self.c.Tr.SelectPrevHunk, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.withRenderAndFocus(self.NextConflictHunk), Description: self.c.Tr.SelectNextHunk, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.PrevBlock), Handler: self.withRenderAndFocus(self.PrevConflict), Description: self.c.Tr.PrevConflict, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.NextBlock), Handler: self.withRenderAndFocus(self.NextConflict), Description: self.c.Tr.NextConflict, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Undo), Handler: self.withRenderAndFocus(self.HandleUndo), Description: self.c.Tr.Undo, Tooltip: self.c.Tr.UndoMergeResolveTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.HandleEditFile, Description: self.c.Tr.EditFile, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenFile), Handler: self.HandleOpenFile, Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt), Handler: self.withRenderAndFocus(self.PrevConflict), }, { Key: opts.GetKey(opts.Config.Universal.NextBlockAlt), Handler: self.withRenderAndFocus(self.NextConflict), }, { Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Handler: self.withRenderAndFocus(self.PrevConflictHunk), }, { Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Handler: self.withRenderAndFocus(self.NextConflictHunk), }, { Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.withRenderAndFocus(self.HandleScrollLeft), Description: self.c.Tr.ScrollLeft, Tag: "navigation", }, { Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.withRenderAndFocus(self.HandleScrollRight), Description: self.c.Tr.ScrollRight, Tag: "navigation", }, { Key: opts.GetKey(opts.Config.Files.OpenMergeTool), Handler: self.c.Helpers().WorkingTree.OpenMergeTool, Description: self.c.Tr.OpenMergeTool, Tooltip: self.c.Tr.OpenMergeToolTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: self.Escape, Description: self.c.Tr.ReturnToFilesPanel, }, } return bindings } func (self *MergeConflictsController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.context().GetViewName(), Key: gocui.MouseWheelUp, Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollUp() }, }, { ViewName: self.context().GetViewName(), Key: gocui.MouseWheelDown, Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollDown() }, }, } } func (self *MergeConflictsController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { self.c.Views().MergeConflicts.Wrap = false self.c.Helpers().MergeConflicts.Render() self.context().SetSelectedLineRange() } } func (self *MergeConflictsController) GetOnFocusLost() func(types.OnFocusLostOpts) { return func(types.OnFocusLostOpts) { self.context().SetUserScrolling(false) self.context().GetState().ResetConflictSelection() self.c.Views().MergeConflicts.Wrap = true } } func (self *MergeConflictsController) HandleScrollUp() error { self.context().SetUserScrolling(true) self.context().GetViewTrait().ScrollUp(self.c.UserConfig().Gui.ScrollHeight) return nil } func (self *MergeConflictsController) HandleScrollDown() error { self.context().SetUserScrolling(true) self.context().GetViewTrait().ScrollDown(self.c.UserConfig().Gui.ScrollHeight) return nil } func (self *MergeConflictsController) Context() types.Context { return self.context() } func (self *MergeConflictsController) context() *context.MergeConflictsContext { return self.c.Contexts().MergeConflicts } func (self *MergeConflictsController) Escape() error { self.c.Context().Pop() return nil } func (self *MergeConflictsController) HandleEditFile() error { lineNumber := self.context().GetState().GetSelectedLine() return self.c.Helpers().Files.EditFileAtLine(self.context().GetState().GetPath(), lineNumber) } func (self *MergeConflictsController) HandleOpenFile() error { return self.c.Helpers().Files.OpenFile(self.context().GetState().GetPath()) } func (self *MergeConflictsController) HandleScrollLeft() error { self.context().GetViewTrait().ScrollLeft() return nil } func (self *MergeConflictsController) HandleScrollRight() error { self.context().GetViewTrait().ScrollRight() return nil } func (self *MergeConflictsController) HandleUndo() error { state := self.context().GetState() ok := state.Undo() if !ok { return nil } self.c.LogAction("Restoring file to previous state") self.c.LogCommand(self.c.Tr.Log.HandleUndo, false) if err := os.WriteFile(state.GetPath(), []byte(state.GetContent()), 0o644); err != nil { return err } return nil } func (self *MergeConflictsController) PrevConflictHunk() error { self.context().SetUserScrolling(false) self.context().GetState().SelectPrevConflictHunk() return nil } func (self *MergeConflictsController) NextConflictHunk() error { self.context().SetUserScrolling(false) self.context().GetState().SelectNextConflictHunk() return nil } func (self *MergeConflictsController) NextConflict() error { self.context().SetUserScrolling(false) self.context().GetState().SelectNextConflict() return nil } func (self *MergeConflictsController) PrevConflict() error { self.context().SetUserScrolling(false) self.context().GetState().SelectPrevConflict() return nil } func (self *MergeConflictsController) HandlePickHunk() error { return self.pickSelection(self.context().GetState().Selection()) } func (self *MergeConflictsController) HandlePickAllHunks() error { return self.pickSelection(mergeconflicts.ALL) } func (self *MergeConflictsController) pickSelection(selection mergeconflicts.Selection) error { ok, err := self.resolveConflict(selection) if err != nil { return err } if !ok { return nil } if self.context().GetState().AllConflictsResolved() { return self.onLastConflictResolved() } return nil } func (self *MergeConflictsController) resolveConflict(selection mergeconflicts.Selection) (bool, error) { self.context().SetUserScrolling(false) state := self.context().GetState() ok, content, err := state.ContentAfterConflictResolve(selection) if err != nil { return false, err } if !ok { return false, nil } var logStr string switch selection { case mergeconflicts.TOP: logStr = "Picking top hunk" case mergeconflicts.MIDDLE: logStr = "Picking middle hunk" case mergeconflicts.BOTTOM: logStr = "Picking bottom hunk" case mergeconflicts.ALL: logStr = "Picking all hunks" } self.c.LogAction("Resolve merge conflict") self.c.LogCommand(logStr, false) state.PushContent(content) return true, os.WriteFile(state.GetPath(), []byte(content), 0o644) } func (self *MergeConflictsController) onLastConflictResolved() error { // as part of refreshing files, we handle the situation where a file has had // its merge conflicts resolved. return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}) } func (self *MergeConflictsController) withRenderAndFocus(f func() error) func() error { return self.withLock(func() error { if err := f(); err != nil { return err } self.context().RenderAndFocus() return nil }) } func (self *MergeConflictsController) withLock(f func() error) func() error { return func() error { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() if self.context().GetState() == nil { return nil } return f() } } lazygit-0.50.0+ds1/pkg/gui/controllers/options_menu_action.go000066400000000000000000000055071500612110400242550ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type OptionsMenuAction struct { c *ControllerCommon } func (self *OptionsMenuAction) Call() error { ctx := self.c.Context().Current() local, global, navigation := self.getBindings(ctx) menuItems := []*types.MenuItem{} appendBindings := func(bindings []*types.Binding, section *types.MenuSection) { menuItems = append(menuItems, lo.Map(bindings, func(binding *types.Binding, _ int) *types.MenuItem { var disabledReason *types.DisabledReason if binding.GetDisabledReason != nil { disabledReason = binding.GetDisabledReason() } return &types.MenuItem{ OpensMenu: binding.OpensMenu, Label: binding.Description, OnPress: func() error { if binding.Handler == nil { return nil } return self.c.IGuiCommon.CallKeybindingHandler(binding) }, Key: binding.Key, Tooltip: binding.Tooltip, DisabledReason: disabledReason, Section: section, } })...) } appendBindings(local, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionLocal, Column: 1}) appendBindings(global, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionGlobal, Column: 1}) appendBindings(navigation, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionNavigation, Column: 1}) return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Keybindings, Items: menuItems, HideCancel: true, ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft}, }) } // Returns three slices of bindings: local, global, and navigation func (self *OptionsMenuAction) getBindings(context types.Context) ([]*types.Binding, []*types.Binding, []*types.Binding) { var bindingsGlobal, bindingsPanel, bindingsNavigation []*types.Binding bindings, _ := self.c.GetInitialKeybindingsWithCustomCommands() for _, binding := range bindings { if keybindings.LabelFromKey(binding.Key) != "" && binding.Description != "" { if binding.ViewName == "" { bindingsGlobal = append(bindingsGlobal, binding) } else if binding.ViewName == context.GetViewName() { if binding.Tag == "navigation" { bindingsNavigation = append(bindingsNavigation, binding) } else { bindingsPanel = append(bindingsPanel, binding) } } } } return uniqueBindings(bindingsPanel), uniqueBindings(bindingsGlobal), uniqueBindings(bindingsNavigation) } // We shouldn't really need to do this. We should define alternative keys for the same // handler in the keybinding struct. func uniqueBindings(bindings []*types.Binding) []*types.Binding { return lo.UniqBy(bindings, func(binding *types.Binding) string { return binding.Description }) } lazygit-0.50.0+ds1/pkg/gui/controllers/patch_building_controller.go000066400000000000000000000110471500612110400254140ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type PatchBuildingController struct { baseController c *ControllerCommon } var _ types.IController = &PatchBuildingController{} func NewPatchBuildingController( c *ControllerCommon, ) *PatchBuildingController { return &PatchBuildingController{ baseController: baseController{}, c: c, } } func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.OpenFile), Handler: self.OpenFile, Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.EditFile, Description: self.c.Tr.EditFile, Tooltip: self.c.Tr.EditFileTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.ToggleSelectionAndRefresh, Description: self.c.Tr.ToggleSelectionForPatch, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: self.Escape, Description: self.c.Tr.ExitCustomPatchBuilder, }, } } func (self *PatchBuildingController) Context() types.Context { return self.c.Contexts().CustomPatchBuilder } func (self *PatchBuildingController) context() types.IPatchExplorerContext { return self.c.Contexts().CustomPatchBuilder } func (self *PatchBuildingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{} } func (self *PatchBuildingController) GetOnFocus() func(types.OnFocusOpts) { return func(opts types.OnFocusOpts) { // no need to change wrap on the secondary view because it can't be interacted with self.c.Views().PatchBuilding.Wrap = self.c.UserConfig().Gui.WrapLinesInStagingView self.c.Helpers().PatchBuilding.RefreshPatchBuildingPanel(opts) } } func (self *PatchBuildingController) GetOnFocusLost() func(types.OnFocusLostOpts) { return func(opts types.OnFocusLostOpts) { self.context().SetState(nil) self.c.Views().PatchBuilding.Wrap = true if self.c.Git().Patch.PatchBuilder.IsEmpty() { self.c.Git().Patch.PatchBuilder.Reset() } } } func (self *PatchBuildingController) OpenFile() error { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() path := self.c.Contexts().CommitFiles.GetSelectedPath() if path == "" { return nil } return self.c.Helpers().Files.OpenFile(path) } func (self *PatchBuildingController) EditFile() error { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() path := self.c.Contexts().CommitFiles.GetSelectedPath() if path == "" { return nil } lineNumber := self.context().GetState().CurrentLineNumber() lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context().GetViewName()) return self.c.Helpers().Files.EditFileAtLine(path, lineNumber) } func (self *PatchBuildingController) ToggleSelectionAndRefresh() error { if err := self.toggleSelection(); err != nil { return err } return self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.PATCH_BUILDING, types.COMMIT_FILES}, }) } func (self *PatchBuildingController) toggleSelection() error { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() toggleFunc := self.c.Git().Patch.PatchBuilder.AddFileLineRange filename := self.c.Contexts().CommitFiles.GetSelectedPath() if filename == "" { return nil } state := self.context().GetState() includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(filename) if err != nil { return err } currentLineIsStaged := lo.Contains(includedLineIndices, state.GetSelectedPatchLineIdx()) if currentLineIsStaged { toggleFunc = self.c.Git().Patch.PatchBuilder.RemoveFileLineRange } // add range of lines to those set for the file firstLineIdx, lastLineIdx := state.SelectedPatchRange() if err := toggleFunc(filename, firstLineIdx, lastLineIdx); err != nil { // might actually want to return an error here self.c.Log.Error(err) } if state.SelectingRange() { state.SetLineSelectMode() } return nil } func (self *PatchBuildingController) Escape() error { context := self.c.Contexts().CustomPatchBuilder state := context.GetState() if state.SelectingRange() || state.SelectingHunk() { state.SetLineSelectMode() self.c.PostRefreshUpdate(context) return nil } self.c.Helpers().PatchBuilding.Escape() return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/patch_explorer_controller.go000066400000000000000000000235101500612110400254550ustar00rootroot00000000000000package controllers import ( "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type PatchExplorerControllerFactory struct { c *ControllerCommon } func NewPatchExplorerControllerFactory(c *ControllerCommon) *PatchExplorerControllerFactory { return &PatchExplorerControllerFactory{ c: c, } } func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController { return &PatchExplorerController{ baseController: baseController{}, c: self.c, context: context, } } type PatchExplorerController struct { baseController c *ControllerCommon context types.IPatchExplorerContext } func (self *PatchExplorerController) Context() types.Context { return self.context } func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Handler: self.withRenderAndFocus(self.HandlePrevLine), }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.withRenderAndFocus(self.HandlePrevLine), }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Handler: self.withRenderAndFocus(self.HandleNextLine), }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.withRenderAndFocus(self.HandleNextLine), }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectUp), Handler: self.withRenderAndFocus(self.HandlePrevLineRange), Description: self.c.Tr.RangeSelectUp, }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectDown), Handler: self.withRenderAndFocus(self.HandleNextLineRange), Description: self.c.Tr.RangeSelectDown, }, { Key: opts.GetKey(opts.Config.Universal.PrevBlock), Handler: self.withRenderAndFocus(self.HandlePrevHunk), Description: self.c.Tr.PrevHunk, }, { Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt), Handler: self.withRenderAndFocus(self.HandlePrevHunk), }, { Key: opts.GetKey(opts.Config.Universal.NextBlock), Handler: self.withRenderAndFocus(self.HandleNextHunk), Description: self.c.Tr.NextHunk, }, { Key: opts.GetKey(opts.Config.Universal.NextBlockAlt), Handler: self.withRenderAndFocus(self.HandleNextHunk), }, { Key: opts.GetKey(opts.Config.Universal.ToggleRangeSelect), Handler: self.withRenderAndFocus(self.HandleToggleSelectRange), Description: self.c.Tr.ToggleRangeSelect, }, { Key: opts.GetKey(opts.Config.Main.ToggleSelectHunk), Handler: self.withRenderAndFocus(self.HandleToggleSelectHunk), Description: self.c.Tr.ToggleSelectHunk, Tooltip: self.c.Tr.ToggleSelectHunkTooltip, DisplayOnScreen: true, }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevPage), Handler: self.withRenderAndFocus(self.HandlePrevPage), Description: self.c.Tr.PrevPage, }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextPage), Handler: self.withRenderAndFocus(self.HandleNextPage), Description: self.c.Tr.NextPage, }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Handler: self.withRenderAndFocus(self.HandleGotoTop), Description: self.c.Tr.GotoTop, }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Description: self.c.Tr.GotoBottom, Handler: self.withRenderAndFocus(self.HandleGotoBottom), }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), Handler: self.withRenderAndFocus(self.HandleGotoTop), }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), Handler: self.withRenderAndFocus(self.HandleGotoBottom), }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.withRenderAndFocus(self.HandleScrollLeft), }, { Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.withRenderAndFocus(self.HandleScrollRight), }, { Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.withLock(self.CopySelectedToClipboard), Description: self.c.Tr.CopySelectedTextToClipboard, }, } } func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.context.GetViewName(), Key: gocui.MouseLeft, Handler: func(opts gocui.ViewMouseBindingOpts) error { if self.isFocused() { return self.withRenderAndFocus(self.HandleMouseDown)() } self.c.Context().Push(self.context, types.OnFocusOpts{ ClickedWindowName: self.context.GetWindowName(), ClickedViewLineIdx: opts.Y, }) return nil }, }, { ViewName: self.context.GetViewName(), Key: gocui.MouseLeft, Modifier: gocui.ModMotion, Handler: func(gocui.ViewMouseBindingOpts) error { return self.withRenderAndFocus(self.HandleMouseDrag)() }, }, } } func (self *PatchExplorerController) HandlePrevLine() error { before := self.context.GetState().GetSelectedViewLineIdx() self.context.GetState().CycleSelection(false) after := self.context.GetState().GetSelectedViewLineIdx() if self.context.GetState().SelectingLine() { checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after) } return nil } func (self *PatchExplorerController) HandleNextLine() error { before := self.context.GetState().GetSelectedViewLineIdx() self.context.GetState().CycleSelection(true) after := self.context.GetState().GetSelectedViewLineIdx() if self.context.GetState().SelectingLine() { checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after) } return nil } func (self *PatchExplorerController) HandlePrevLineRange() error { s := self.context.GetState() s.CycleRange(false) return nil } func (self *PatchExplorerController) HandleNextLineRange() error { s := self.context.GetState() s.CycleRange(true) return nil } func (self *PatchExplorerController) HandlePrevHunk() error { self.context.GetState().CycleHunk(false) return nil } func (self *PatchExplorerController) HandleNextHunk() error { self.context.GetState().CycleHunk(true) return nil } func (self *PatchExplorerController) HandleToggleSelectRange() error { self.context.GetState().ToggleStickySelectRange() return nil } func (self *PatchExplorerController) HandleToggleSelectHunk() error { self.context.GetState().ToggleSelectHunk() return nil } func (self *PatchExplorerController) HandleScrollLeft() error { self.context.GetViewTrait().ScrollLeft() return nil } func (self *PatchExplorerController) HandleScrollRight() error { self.context.GetViewTrait().ScrollRight() return nil } func (self *PatchExplorerController) HandlePrevPage() error { self.context.GetState().AdjustSelectedLineIdx(-self.context.GetViewTrait().PageDelta()) return nil } func (self *PatchExplorerController) HandleNextPage() error { self.context.GetState().AdjustSelectedLineIdx(self.context.GetViewTrait().PageDelta()) return nil } func (self *PatchExplorerController) HandleGotoTop() error { self.context.GetState().SelectTop() return nil } func (self *PatchExplorerController) HandleGotoBottom() error { self.context.GetState().SelectBottom() return nil } func (self *PatchExplorerController) HandleMouseDown() error { self.context.GetState().SelectNewLineForRange(self.context.GetViewTrait().SelectedLineIdx()) return nil } func (self *PatchExplorerController) HandleMouseDrag() error { self.context.GetState().DragSelectLine(self.context.GetViewTrait().SelectedLineIdx()) return nil } func (self *PatchExplorerController) CopySelectedToClipboard() error { selected := self.context.GetState().PlainRenderSelected() self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard) if err := self.c.OS().CopyToClipboard(dropDiffPrefix(selected)); err != nil { return err } return nil } // Removes '+' or '-' from the beginning of each line in the diff string, except // when both '+' and '-' lines are present, or diff header lines, in which case // the diff is returned unchanged. This is useful for copying parts of diffs to // the clipboard in order to paste them into code. func dropDiffPrefix(diff string) string { lines := strings.Split(strings.TrimRight(diff, "\n"), "\n") const ( PLUS int = iota MINUS CONTEXT OTHER ) linesByType := lo.GroupBy(lines, func(line string) int { switch { case strings.HasPrefix(line, "+"): return PLUS case strings.HasPrefix(line, "-"): return MINUS case strings.HasPrefix(line, " "): return CONTEXT } return OTHER }) hasLinesOfType := func(lineType int) bool { return len(linesByType[lineType]) > 0 } keepPrefix := hasLinesOfType(OTHER) || (hasLinesOfType(PLUS) && hasLinesOfType(MINUS)) if keepPrefix { return diff } return strings.Join(lo.Map(lines, func(line string, _ int) string { return line[1:] + "\n" }), "") } func (self *PatchExplorerController) isFocused() bool { return self.c.Context().Current().GetKey() == self.context.GetKey() } func (self *PatchExplorerController) withRenderAndFocus(f func() error) func() error { return self.withLock(func() error { if err := f(); err != nil { return err } self.context.RenderAndFocus() return nil }) } func (self *PatchExplorerController) withLock(f func() error) func() error { return func() error { self.context.GetMutex().Lock() defer self.context.GetMutex().Unlock() if self.context.GetState() == nil { return nil } return f() } } lazygit-0.50.0+ds1/pkg/gui/controllers/patch_explorer_controller_test.go000066400000000000000000000022651500612110400265200ustar00rootroot00000000000000package controllers import ( "testing" "github.com/stretchr/testify/assert" ) func Test_dropDiffPrefix(t *testing.T) { scenarios := []struct { name string diff string expectedResult string }{ { name: "empty string", diff: "", expectedResult: "", }, { name: "only added lines", diff: `+line1 +line2 `, expectedResult: `line1 line2 `, }, { name: "added lines with context", diff: ` line1 +line2 `, expectedResult: `line1 line2 `, }, { name: "only deleted lines", diff: `-line1 -line2 `, expectedResult: `line1 line2 `, }, { name: "deleted lines with context", diff: `-line1 line2 `, expectedResult: `line1 line2 `, }, { name: "only context", diff: ` line1 line2 `, expectedResult: `line1 line2 `, }, { name: "added and deleted lines", diff: `+line1 -line2 `, expectedResult: `+line1 -line2 `, }, { name: "hunk header lines", diff: `@@ -1,8 +1,11 @@ line1 `, expectedResult: `@@ -1,8 +1,11 @@ line1 `, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { assert.Equal(t, s.expectedResult, dropDiffPrefix(s.diff)) }) } } lazygit-0.50.0+ds1/pkg/gui/controllers/quit_actions.go000066400000000000000000000043711500612110400227010ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type QuitActions struct { c *ControllerCommon } func (self *QuitActions) Quit() error { self.c.State().SetRetainOriginalDir(false) return self.quitAux() } func (self *QuitActions) QuitWithoutChangingDirectory() error { self.c.State().SetRetainOriginalDir(true) return self.quitAux() } func (self *QuitActions) quitAux() error { if self.c.State().GetUpdating() { return self.confirmQuitDuringUpdate() } if self.c.UserConfig().ConfirmOnQuit { self.c.Confirm(types.ConfirmOpts{ Title: "", Prompt: self.c.Tr.ConfirmQuit, HandleConfirm: func() error { return gocui.ErrQuit }, }) return nil } return gocui.ErrQuit } func (self *QuitActions) confirmQuitDuringUpdate() error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.ConfirmQuitDuringUpdateTitle, Prompt: self.c.Tr.ConfirmQuitDuringUpdate, HandleConfirm: func() error { return gocui.ErrQuit }, }) return nil } func (self *QuitActions) Escape() error { currentContext := self.c.Context().Current() if listContext, ok := currentContext.(types.IListContext); ok { if listContext.GetList().IsSelectingRange() { listContext.GetList().CancelRangeSelect() self.c.PostRefreshUpdate(listContext) return nil } } switch ctx := currentContext.(type) { case types.IFilterableContext: if ctx.IsFiltering() { self.c.Helpers().Search.Cancel() return nil } case types.ISearchableContext: if ctx.IsSearching() { self.c.Helpers().Search.Cancel() return nil } } parentContext := currentContext.GetParentContext() if parentContext != nil { // TODO: think about whether this should be marked as a return rather than adding to the stack self.c.Context().Push(parentContext, types.OnFocusOpts{}) return nil } for _, mode := range self.c.Helpers().Mode.Statuses() { if mode.IsActive() { return mode.Reset() } } repoPathStack := self.c.State().GetRepoPathStack() if !repoPathStack.IsEmpty() { return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), context.NO_CONTEXT) } if self.c.UserConfig().QuitOnTopLevelReturn { return self.Quit() } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/reflog_commits_controller.go000066400000000000000000000030321500612110400254440ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ReflogCommitsController struct { baseController *ListControllerTrait[*models.Commit] c *ControllerCommon } var _ types.IController = &ReflogCommitsController{} func NewReflogCommitsController( c *ControllerCommon, ) *ReflogCommitsController { return &ReflogCommitsController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().ReflogCommits, c.Contexts().ReflogCommits.GetSelected, c.Contexts().ReflogCommits.GetSelectedItems, ), c: c, } } func (self *ReflogCommitsController) Context() types.Context { return self.context() } func (self *ReflogCommitsController) context() *context.ReflogCommitsContext { return self.c.Contexts().ReflogCommits } func (self *ReflogCommitsController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { commit := self.context().GetSelected() var task types.UpdateTask if commit == nil { task = types.NewRenderStringTask("No reflog history") } else { cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Modes().Filtering.GetPath()) task = types.NewRunPtyTask(cmdObj.GetCmd()) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Reflog Entry", Task: task, }, }) }) } } lazygit-0.50.0+ds1/pkg/gui/controllers/remote_branches_controller.go000066400000000000000000000156301500612110400256020ustar00rootroot00000000000000package controllers import ( "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type RemoteBranchesController struct { baseController *ListControllerTrait[*models.RemoteBranch] c *ControllerCommon } var _ types.IController = &RemoteBranchesController{} func NewRemoteBranchesController( c *ControllerCommon, ) *RemoteBranchesController { return &RemoteBranchesController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().RemoteBranches, c.Contexts().RemoteBranches.GetSelected, c.Contexts().RemoteBranches.GetSelectedItems, ), c: c, } } func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItem(self.checkoutBranch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Checkout, Tooltip: self.c.Tr.RemoteBranchCheckoutTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.New), Handler: self.withItem(self.newLocalBranch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewBranch, }, { Key: opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.merge)), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Merge, Tooltip: self.c.Tr.MergeBranchTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.RebaseBranch), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.RebaseBranch, Tooltip: self.c.Tr.RebaseBranchTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItems(self.delete), GetDisabledReason: self.require(self.itemRangeSelected()), Description: self.c.Tr.Delete, Tooltip: self.c.Tr.DeleteRemoteBranchTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.SetUpstream), Handler: self.withItem(self.setAsUpstream), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.SetAsUpstream, Tooltip: self.c.Tr.SetAsUpstreamTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.SortOrder), Handler: self.createSortMenu, Description: self.c.Tr.SortOrder, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewResetOptions, Tooltip: self.c.Tr.ResetTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(func(selectedBranch *models.RemoteBranch) error { return self.c.Helpers().Diff.OpenDiffToolForRef(selectedBranch) }), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, } } func (self *RemoteBranchesController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { var task types.UpdateTask remoteBranch := self.context().GetSelected() if remoteBranch == nil { task = types.NewRenderStringTask("No branches for this remote") } else { cmdObj := self.c.Git().Branch.GetGraphCmdObj(remoteBranch.FullRefName()) task = types.NewRunCommandTask(cmdObj.GetCmd()) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Remote Branch", Task: task, }, }) }) } } func (self *RemoteBranchesController) context() *context.RemoteBranchesContext { return self.c.Contexts().RemoteBranches } func (self *RemoteBranchesController) delete(selectedBranches []*models.RemoteBranch) error { return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(selectedBranches) } func (self *RemoteBranchesController) merge(selectedBranch *models.RemoteBranch) error { return self.c.Helpers().MergeAndRebase.MergeRefIntoCheckedOutBranch(selectedBranch.FullName()) } func (self *RemoteBranchesController) rebase(selectedBranch *models.RemoteBranch) error { return self.c.Helpers().MergeAndRebase.RebaseOntoRef(selectedBranch.FullName()) } func (self *RemoteBranchesController) createSortMenu() error { return self.c.Helpers().Refs.CreateSortOrderMenu([]string{"alphabetical", "date"}, func(sortOrder string) error { if self.c.GetAppState().RemoteBranchSortOrder != sortOrder { self.c.GetAppState().RemoteBranchSortOrder = sortOrder self.c.SaveAppStateAndLogError() self.c.Contexts().RemoteBranches.SetSelection(0) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.REMOTES}}) } return nil }, self.c.GetAppState().RemoteBranchSortOrder) } func (self *RemoteBranchesController) createResetMenu(selectedBranch *models.RemoteBranch) error { return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.FullName()) } func (self *RemoteBranchesController) setAsUpstream(selectedBranch *models.RemoteBranch) error { checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef() message := utils.ResolvePlaceholderString( self.c.Tr.SetUpstreamMessage, map[string]string{ "checkedOut": checkedOutBranch.Name, "selected": selectedBranch.FullName(), }, ) self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.SetUpstreamTitle, Prompt: message, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.SetBranchUpstream) if err := self.c.Git().Branch.SetUpstream(selectedBranch.RemoteName, selectedBranch.Name, checkedOutBranch.Name); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) }, }) return nil } func (self *RemoteBranchesController) newLocalBranch(selectedBranch *models.RemoteBranch) error { // will set to the remote's branch name without the remote name nameSuggestion := strings.SplitAfterN(selectedBranch.RefName(), "/", 2)[1] return self.c.Helpers().Refs.NewBranch(selectedBranch.RefName(), selectedBranch.RefName(), nameSuggestion) } func (self *RemoteBranchesController) checkoutBranch(selectedBranch *models.RemoteBranch) error { return self.c.Helpers().Refs.CheckoutRemoteBranch(selectedBranch.FullName(), selectedBranch.Name) } lazygit-0.50.0+ds1/pkg/gui/controllers/remotes_controller.go000066400000000000000000000163331500612110400241210ustar00rootroot00000000000000package controllers import ( "fmt" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type RemotesController struct { baseController *ListControllerTrait[*models.Remote] c *ControllerCommon setRemoteBranches func([]*models.RemoteBranch) } var _ types.IController = &RemotesController{} func NewRemotesController( c *ControllerCommon, setRemoteBranches func([]*models.RemoteBranch), ) *RemotesController { return &RemotesController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Remotes, c.Contexts().Remotes.GetSelected, c.Contexts().Remotes.GetSelectedItems, ), c: c, setRemoteBranches: setRemoteBranches, } } func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.GoInto), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewBranches, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.New), Handler: self.add, Description: self.c.Tr.NewRemote, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItem(self.remove), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Remove, Tooltip: self.c.Tr.RemoveRemoteTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.withItem(self.edit), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Edit, Tooltip: self.c.Tr.EditRemoteTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.FetchRemote), Handler: self.withItem(self.fetch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Fetch, Tooltip: self.c.Tr.FetchRemoteTooltip, DisplayOnScreen: true, }, } return bindings } func (self *RemotesController) context() *context.RemotesContext { return self.c.Contexts().Remotes } func (self *RemotesController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { var task types.UpdateTask remote := self.context().GetSelected() if remote == nil { task = types.NewRenderStringTask("No remotes") } else { task = types.NewRenderStringTask(fmt.Sprintf("%s\nUrls:\n%s", style.FgGreen.Sprint(remote.Name), strings.Join(remote.Urls, "\n"))) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Remote", Task: task, }, }) }) } } func (self *RemotesController) GetOnClick() func() error { return self.withItemGraceful(self.enter) } func (self *RemotesController) enter(remote *models.Remote) error { // naive implementation: get the branches from the remote and render them to the list, change the context self.setRemoteBranches(remote.Branches) newSelectedLine := 0 if len(remote.Branches) == 0 { newSelectedLine = -1 } remoteBranchesContext := self.c.Contexts().RemoteBranches remoteBranchesContext.SetSelection(newSelectedLine) remoteBranchesContext.SetTitleRef(remote.Name) remoteBranchesContext.SetParentContext(self.Context()) remoteBranchesContext.GetView().TitlePrefix = self.Context().GetView().TitlePrefix self.c.PostRefreshUpdate(remoteBranchesContext) self.c.Context().Push(remoteBranchesContext, types.OnFocusOpts{}) return nil } func (self *RemotesController) add() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewRemoteName, HandleConfirm: func(remoteName string) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewRemoteUrl, HandleConfirm: func(remoteUrl string) error { self.c.LogAction(self.c.Tr.Actions.AddRemote) if err := self.c.Git().Remote.AddRemote(remoteName, remoteUrl); err != nil { return err } // Do a sync refresh of the remotes so that we can select // the new one. Loading remotes is not expensive, so we can // afford it. if err := self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.REMOTES}, Mode: types.SYNC, }); err != nil { return err } // Select the new remote for idx, remote := range self.c.Model().Remotes { if remote.Name == remoteName { self.c.Contexts().Remotes.SetSelection(idx) break } } // Fetch the new remote return self.fetch(self.c.Contexts().Remotes.GetSelected()) }, }) return nil }, }) return nil } func (self *RemotesController) remove(remote *models.Remote) error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.RemoveRemote, Prompt: self.c.Tr.RemoveRemotePrompt + " '" + remote.Name + "'?", HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RemoveRemote) if err := self.c.Git().Remote.RemoveRemote(remote.Name); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) }, }) return nil } func (self *RemotesController) edit(remote *models.Remote) error { editNameMessage := utils.ResolvePlaceholderString( self.c.Tr.EditRemoteName, map[string]string{ "remoteName": remote.Name, }, ) self.c.Prompt(types.PromptOpts{ Title: editNameMessage, InitialContent: remote.Name, HandleConfirm: func(updatedRemoteName string) error { if updatedRemoteName != remote.Name { self.c.LogAction(self.c.Tr.Actions.UpdateRemote) if err := self.c.Git().Remote.RenameRemote(remote.Name, updatedRemoteName); err != nil { return err } } editUrlMessage := utils.ResolvePlaceholderString( self.c.Tr.EditRemoteUrl, map[string]string{ "remoteName": updatedRemoteName, }, ) urls := remote.Urls url := "" if len(urls) > 0 { url = urls[0] } self.c.Prompt(types.PromptOpts{ Title: editUrlMessage, InitialContent: url, HandleConfirm: func(updatedRemoteUrl string) error { self.c.LogAction(self.c.Tr.Actions.UpdateRemote) if err := self.c.Git().Remote.UpdateRemoteUrl(updatedRemoteName, updatedRemoteUrl); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) }, }) return nil }, }) return nil } func (self *RemotesController) fetch(remote *models.Remote) error { return self.c.WithInlineStatus(remote, types.ItemOperationFetching, context.REMOTES_CONTEXT_KEY, func(task gocui.Task) error { err := self.c.Git().Sync.FetchRemote(task, remote.Name) if err != nil { return err } return self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, Mode: types.ASYNC, }) }) } lazygit-0.50.0+ds1/pkg/gui/controllers/rename_similarity_threshold_controller.go000066400000000000000000000064031500612110400302310ustar00rootroot00000000000000package controllers import ( "fmt" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) // This controller lets you change the similarity threshold for detecting renames. var CONTEXT_KEYS_SHOWING_RENAMES = []types.ContextKey{ context.FILES_CONTEXT_KEY, context.SUB_COMMITS_CONTEXT_KEY, context.LOCAL_COMMITS_CONTEXT_KEY, context.STASH_CONTEXT_KEY, context.NORMAL_MAIN_CONTEXT_KEY, context.NORMAL_SECONDARY_CONTEXT_KEY, } type RenameSimilarityThresholdController struct { baseController c *ControllerCommon } var _ types.IController = &RenameSimilarityThresholdController{} func NewRenameSimilarityThresholdController( common *ControllerCommon, ) *RenameSimilarityThresholdController { return &RenameSimilarityThresholdController{ baseController: baseController{}, c: common, } } func (self *RenameSimilarityThresholdController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.IncreaseRenameSimilarityThreshold), Handler: self.Increase, Description: self.c.Tr.IncreaseRenameSimilarityThreshold, Tooltip: self.c.Tr.IncreaseRenameSimilarityThresholdTooltip, }, { Key: opts.GetKey(opts.Config.Universal.DecreaseRenameSimilarityThreshold), Handler: self.Decrease, Description: self.c.Tr.DecreaseRenameSimilarityThreshold, Tooltip: self.c.Tr.DecreaseRenameSimilarityThresholdTooltip, }, } return bindings } func (self *RenameSimilarityThresholdController) Context() types.Context { return nil } func (self *RenameSimilarityThresholdController) Increase() error { old_size := self.c.AppState.RenameSimilarityThreshold if self.isShowingRenames() && old_size < 100 { self.c.AppState.RenameSimilarityThreshold = min(100, old_size+5) return self.applyChange() } return nil } func (self *RenameSimilarityThresholdController) Decrease() error { old_size := self.c.AppState.RenameSimilarityThreshold if self.isShowingRenames() && old_size > 5 { self.c.AppState.RenameSimilarityThreshold = max(5, old_size-5) return self.applyChange() } return nil } func (self *RenameSimilarityThresholdController) applyChange() error { self.c.Toast(fmt.Sprintf(self.c.Tr.RenameSimilarityThresholdChanged, self.c.AppState.RenameSimilarityThreshold)) self.c.SaveAppStateAndLogError() currentContext := self.currentSidePanel() switch currentContext.GetKey() { // we make an exception for our files context, because it actually need to refresh its state afterwards. case context.FILES_CONTEXT_KEY: return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) default: currentContext.HandleRenderToMain() return nil } } func (self *RenameSimilarityThresholdController) isShowingRenames() bool { return lo.Contains( CONTEXT_KEYS_SHOWING_RENAMES, self.currentSidePanel().GetKey(), ) } func (self *RenameSimilarityThresholdController) currentSidePanel() types.Context { currentContext := self.c.Context().CurrentStatic() if currentContext.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY || currentContext.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY { return currentContext.GetParentContext() } return currentContext } lazygit-0.50.0+ds1/pkg/gui/controllers/screen_mode_actions.go000066400000000000000000000036531500612110400242040ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ScreenModeActions struct { c *ControllerCommon } func (self *ScreenModeActions) Next() error { self.c.State().GetRepoState().SetScreenMode( nextIntInCycle( []types.ScreenMode{types.SCREEN_NORMAL, types.SCREEN_HALF, types.SCREEN_FULL}, self.c.State().GetRepoState().GetScreenMode(), ), ) self.rerenderViewsWithScreenModeDependentContent() return nil } func (self *ScreenModeActions) Prev() error { self.c.State().GetRepoState().SetScreenMode( prevIntInCycle( []types.ScreenMode{types.SCREEN_NORMAL, types.SCREEN_HALF, types.SCREEN_FULL}, self.c.State().GetRepoState().GetScreenMode(), ), ) self.rerenderViewsWithScreenModeDependentContent() return nil } // these views need to be re-rendered when the screen mode changes. The commits view, // for example, will show authorship information in half and full screen mode. func (self *ScreenModeActions) rerenderViewsWithScreenModeDependentContent() { for _, context := range self.c.Context().AllList() { if context.NeedsRerenderOnWidthChange() == types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_SCREEN_MODE_CHANGES { self.rerenderView(context.GetView()) } } } func (self *ScreenModeActions) rerenderView(view *gocui.View) { context, ok := self.c.Helpers().View.ContextForView(view.Name()) if !ok { self.c.Log.Errorf("no context found for view %s", view.Name()) return } context.HandleRender() } func nextIntInCycle(sl []types.ScreenMode, current types.ScreenMode) types.ScreenMode { for i, val := range sl { if val == current { if i == len(sl)-1 { return sl[0] } return sl[i+1] } } return sl[0] } func prevIntInCycle(sl []types.ScreenMode, current types.ScreenMode) types.ScreenMode { for i, val := range sl { if val == current { if i > 0 { return sl[i-1] } return sl[len(sl)-1] } } return sl[len(sl)-1] } lazygit-0.50.0+ds1/pkg/gui/controllers/scroll_off_margin.go000066400000000000000000000061441500612110400236640ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" ) // To be called after pressing up-arrow; checks whether the cursor entered the // top scroll-off margin, and so the view needs to be scrolled up one line func checkScrollUp(view types.IViewTrait, userConfig *config.UserConfig, lineIdxBefore int, lineIdxAfter int) { if userConfig.Gui.ScrollOffBehavior != "jump" { viewPortStart, viewPortHeight := view.ViewPortYBounds() linesToScroll := calculateLinesToScrollUp( viewPortStart, viewPortHeight, userConfig.Gui.ScrollOffMargin, lineIdxBefore, lineIdxAfter) if linesToScroll != 0 { view.ScrollUp(linesToScroll) } } } // To be called after pressing down-arrow; checks whether the cursor entered the // bottom scroll-off margin, and so the view needs to be scrolled down one line func checkScrollDown(view types.IViewTrait, userConfig *config.UserConfig, lineIdxBefore int, lineIdxAfter int) { if userConfig.Gui.ScrollOffBehavior != "jump" { viewPortStart, viewPortHeight := view.ViewPortYBounds() linesToScroll := calculateLinesToScrollDown( viewPortStart, viewPortHeight, userConfig.Gui.ScrollOffMargin, lineIdxBefore, lineIdxAfter) if linesToScroll != 0 { view.ScrollDown(linesToScroll) } } } func calculateLinesToScrollUp(viewPortStart int, viewPortHeight int, scrollOffMargin int, lineIdxBefore int, lineIdxAfter int) int { // Cap the margin to half the view height. This allows setting the config to // a very large value to keep the cursor always in the middle of the screen. // Use +.5 so that if the height is even, the top margin is one line higher // than the bottom margin. scrollOffMargin = min(scrollOffMargin, int((float64(viewPortHeight)+.5)/2)) // Scroll only if the "before" position was visible (this could be false if // the scroll wheel was used to scroll the selected line out of view) ... if lineIdxBefore >= viewPortStart && lineIdxBefore < viewPortStart+viewPortHeight { marginEnd := viewPortStart + scrollOffMargin // ... and the "after" position is within the top margin (or before it) if lineIdxAfter < marginEnd { return marginEnd - lineIdxAfter } } return 0 } func calculateLinesToScrollDown(viewPortStart int, viewPortHeight int, scrollOffMargin int, lineIdxBefore int, lineIdxAfter int) int { // Cap the margin to half the view height. This allows setting the config to // a very large value to keep the cursor always in the middle of the screen. // Use -.5 so that if the height is even, the bottom margin is one line lower // than the top margin. scrollOffMargin = min(scrollOffMargin, int((float64(viewPortHeight)-.5)/2)) // Scroll only if the "before" position was visible (this could be false if // the scroll wheel was used to scroll the selected line out of view) ... if lineIdxBefore >= viewPortStart && lineIdxBefore < viewPortStart+viewPortHeight { marginStart := viewPortStart + viewPortHeight - scrollOffMargin - 1 // ... and the "after" position is within the bottom margin (or after it) if lineIdxAfter > marginStart { return lineIdxAfter - marginStart } } return 0 } lazygit-0.50.0+ds1/pkg/gui/controllers/scroll_off_margin_test.go000066400000000000000000000131461500612110400247230ustar00rootroot00000000000000package controllers import ( "testing" "github.com/stretchr/testify/assert" ) func Test_calculateLinesToScrollUp(t *testing.T) { scenarios := []struct { name string viewPortStart int viewPortHeight int scrollOffMargin int lineIdxBefore int lineIdxAfter int expectedLinesToScroll int }{ { name: "before position is above viewport - don't scroll", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 9, lineIdxAfter: 8, expectedLinesToScroll: 0, }, { name: "before position is below viewport - don't scroll", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 20, lineIdxAfter: 19, expectedLinesToScroll: 0, }, { name: "before and after positions are outside scroll-off margin - don't scroll", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 14, lineIdxAfter: 13, expectedLinesToScroll: 0, }, { name: "before outside, after inside scroll-off margin - scroll by 1", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 13, lineIdxAfter: 12, expectedLinesToScroll: 1, }, { name: "scroll-off margin is zero - scroll by 1 at end of view", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 0, lineIdxBefore: 10, lineIdxAfter: 9, expectedLinesToScroll: 1, }, { name: "before inside scroll-off margin - scroll by more than 1", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 11, lineIdxAfter: 10, expectedLinesToScroll: 3, }, { name: "very large scroll-off margin - keep view centered (even viewport height)", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 999, lineIdxBefore: 15, lineIdxAfter: 14, expectedLinesToScroll: 1, }, { name: "very large scroll-off margin - keep view centered (odd viewport height)", viewPortStart: 10, viewPortHeight: 9, scrollOffMargin: 999, lineIdxBefore: 14, lineIdxAfter: 13, expectedLinesToScroll: 1, }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { linesToScroll := calculateLinesToScrollUp(scenario.viewPortStart, scenario.viewPortHeight, scenario.scrollOffMargin, scenario.lineIdxBefore, scenario.lineIdxAfter) assert.Equal(t, scenario.expectedLinesToScroll, linesToScroll) }) } } func Test_calculateLinesToScrollDown(t *testing.T) { scenarios := []struct { name string viewPortStart int viewPortHeight int scrollOffMargin int lineIdxBefore int lineIdxAfter int expectedLinesToScroll int }{ { name: "before position is above viewport - don't scroll", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 9, lineIdxAfter: 10, expectedLinesToScroll: 0, }, { name: "before position is below viewport - don't scroll", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 20, lineIdxAfter: 21, expectedLinesToScroll: 0, }, { name: "before and after positions are outside scroll-off margin - don't scroll", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 15, lineIdxAfter: 16, expectedLinesToScroll: 0, }, { name: "before outside, after inside scroll-off margin - scroll by 1", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 16, lineIdxAfter: 17, expectedLinesToScroll: 1, }, { name: "scroll-off margin is zero - scroll by 1 at end of view", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 0, lineIdxBefore: 19, lineIdxAfter: 20, expectedLinesToScroll: 1, }, { name: "before inside scroll-off margin - scroll by more than 1", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 3, lineIdxBefore: 18, lineIdxAfter: 19, expectedLinesToScroll: 3, }, { name: "very large scroll-off margin - keep view centered (even viewport height)", viewPortStart: 10, viewPortHeight: 10, scrollOffMargin: 999, lineIdxBefore: 15, lineIdxAfter: 16, expectedLinesToScroll: 1, }, { name: "very large scroll-off margin - keep view centered (odd viewport height)", viewPortStart: 10, viewPortHeight: 9, scrollOffMargin: 999, lineIdxBefore: 14, lineIdxAfter: 15, expectedLinesToScroll: 1, }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { linesToScroll := calculateLinesToScrollDown(scenario.viewPortStart, scenario.viewPortHeight, scenario.scrollOffMargin, scenario.lineIdxBefore, scenario.lineIdxAfter) assert.Equal(t, scenario.expectedLinesToScroll, linesToScroll) }) } } lazygit-0.50.0+ds1/pkg/gui/controllers/search_controller.go000066400000000000000000000020641500612110400237040ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/types" ) type SearchControllerFactory struct { c *ControllerCommon } func NewSearchControllerFactory(c *ControllerCommon) *SearchControllerFactory { return &SearchControllerFactory{ c: c, } } func (self *SearchControllerFactory) Create(context types.ISearchableContext) *SearchController { return &SearchController{ baseController: baseController{}, c: self.c, context: context, } } type SearchController struct { baseController c *ControllerCommon context types.ISearchableContext } func (self *SearchController) Context() types.Context { return self.context } func (self *SearchController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.StartSearch), Handler: self.OpenSearchPrompt, Description: self.c.Tr.StartSearch, }, } } func (self *SearchController) OpenSearchPrompt() error { return self.c.Helpers().Search.OpenSearchPrompt(self.context) } lazygit-0.50.0+ds1/pkg/gui/controllers/search_prompt_controller.go000066400000000000000000000032011500612110400252770ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type SearchPromptController struct { baseController c *ControllerCommon } var _ types.IController = &SearchPromptController{} func NewSearchPromptController( c *ControllerCommon, ) *SearchPromptController { return &SearchPromptController{ baseController: baseController{}, c: c, } } func (self *SearchPromptController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Confirm), Modifier: gocui.ModNone, Handler: self.confirm, }, { Key: opts.GetKey(opts.Config.Universal.Return), Modifier: gocui.ModNone, Handler: self.cancel, }, { Key: opts.GetKey(opts.Config.Universal.PrevItem), Modifier: gocui.ModNone, Handler: self.prevHistory, }, { Key: opts.GetKey(opts.Config.Universal.NextItem), Modifier: gocui.ModNone, Handler: self.nextHistory, }, } } func (self *SearchPromptController) Context() types.Context { return self.context() } func (self *SearchPromptController) context() types.Context { return self.c.Contexts().Search } func (self *SearchPromptController) confirm() error { return self.c.Helpers().Search.Confirm() } func (self *SearchPromptController) cancel() error { return self.c.Helpers().Search.CancelPrompt() } func (self *SearchPromptController) prevHistory() error { self.c.Helpers().Search.ScrollHistory(1) return nil } func (self *SearchPromptController) nextHistory() error { self.c.Helpers().Search.ScrollHistory(-1) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/shell_command_action.go000066400000000000000000000045171500612110400243430ustar00rootroot00000000000000package controllers import ( "slices" "strings" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type ShellCommandAction struct { c *ControllerCommon } func (self *ShellCommandAction) Call() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.ShellCommand, FindSuggestionsFunc: self.GetShellCommandsHistorySuggestionsFunc(), AllowEditSuggestion: true, HandleConfirm: func(command string) error { if self.shouldSaveCommand(command) { self.c.GetAppState().ShellCommandsHistory = utils.Limit( lo.Uniq(append([]string{command}, self.c.GetAppState().ShellCommandsHistory...)), 1000, ) } self.c.SaveAppStateAndLogError() self.c.LogAction(self.c.Tr.Actions.CustomCommand) return self.c.RunSubprocessAndRefresh( self.c.OS().Cmd.NewShell(command, self.c.UserConfig().OS.ShellFunctionsFile), ) }, HandleDeleteSuggestion: func(index int) error { // index is the index in the _filtered_ list of suggestions, so we // need to map it back to the full list. There's no really good way // to do this, but fortunately we keep the items in the // ShellCommandsHistory unique, which allows us to simply search // for it by string. item := self.c.Contexts().Suggestions.GetItems()[index].Value fullIndex := lo.IndexOf(self.c.GetAppState().ShellCommandsHistory, item) if fullIndex == -1 { // Should never happen, but better be safe return nil } self.c.GetAppState().ShellCommandsHistory = slices.Delete( self.c.GetAppState().ShellCommandsHistory, fullIndex, fullIndex+1) self.c.SaveAppStateAndLogError() self.c.Contexts().Suggestions.RefreshSuggestions() return nil }, }) return nil } func (self *ShellCommandAction) GetShellCommandsHistorySuggestionsFunc() func(string) []*types.Suggestion { return func(input string) []*types.Suggestion { history := self.c.GetAppState().ShellCommandsHistory return helpers.FilterFunc(history, self.c.UserConfig().Gui.UseFuzzySearch())(input) } } // this mimics the shell functionality `ignorespace` // which doesn't save a command to history if it starts with a space func (self *ShellCommandAction) shouldSaveCommand(command string) bool { return !strings.HasPrefix(command, " ") } lazygit-0.50.0+ds1/pkg/gui/controllers/side_window_controller.go000066400000000000000000000054611500612110400247560ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type SideWindowControllerFactory struct { c *ControllerCommon } func NewSideWindowControllerFactory(c *ControllerCommon) *SideWindowControllerFactory { return &SideWindowControllerFactory{c: c} } func (self *SideWindowControllerFactory) Create(context types.Context) types.IController { return NewSideWindowController(self.c, context) } type SideWindowController struct { baseController c *ControllerCommon context types.Context } func NewSideWindowController( c *ControllerCommon, context types.Context, ) *SideWindowController { return &SideWindowController{ baseController: baseController{}, c: c, context: context, } } func (self *SideWindowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ {Key: opts.GetKey(opts.Config.Universal.PrevBlock), Modifier: gocui.ModNone, Handler: self.previousSideWindow}, {Key: opts.GetKey(opts.Config.Universal.NextBlock), Modifier: gocui.ModNone, Handler: self.nextSideWindow}, {Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt), Modifier: gocui.ModNone, Handler: self.previousSideWindow}, {Key: opts.GetKey(opts.Config.Universal.NextBlockAlt), Modifier: gocui.ModNone, Handler: self.nextSideWindow}, {Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt2), Modifier: gocui.ModNone, Handler: self.previousSideWindow}, {Key: opts.GetKey(opts.Config.Universal.NextBlockAlt2), Modifier: gocui.ModNone, Handler: self.nextSideWindow}, } } func (self *SideWindowController) Context() types.Context { return nil } func (self *SideWindowController) previousSideWindow() error { windows := self.c.Helpers().Window.SideWindows() currentWindow := self.c.Helpers().Window.CurrentWindow() var newWindow string if currentWindow == "" || currentWindow == windows[0] { newWindow = windows[len(windows)-1] } else { for i := range windows { if currentWindow == windows[i] { newWindow = windows[i-1] break } if i == len(windows)-1 { return nil } } } context := self.c.Helpers().Window.GetContextForWindow(newWindow) self.c.Context().Push(context, types.OnFocusOpts{}) return nil } func (self *SideWindowController) nextSideWindow() error { windows := self.c.Helpers().Window.SideWindows() currentWindow := self.c.Helpers().Window.CurrentWindow() var newWindow string if currentWindow == "" || currentWindow == windows[len(windows)-1] { newWindow = windows[0] } else { for i := range windows { if currentWindow == windows[i] { newWindow = windows[i+1] break } if i == len(windows)-1 { return nil } } } context := self.c.Helpers().Window.GetContextForWindow(newWindow) self.c.Context().Push(context, types.OnFocusOpts{}) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/snake_controller.go000066400000000000000000000034701500612110400235420ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/snake" ) type SnakeController struct { baseController c *ControllerCommon } var _ types.IController = &SnakeController{} func NewSnakeController( c *ControllerCommon, ) *SnakeController { return &SnakeController{ baseController: baseController{}, c: c, } } func (self *SnakeController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.SetDirection(snake.Down), }, { Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.SetDirection(snake.Up), }, { Key: opts.GetKey(opts.Config.Universal.PrevBlock), Handler: self.SetDirection(snake.Left), }, { Key: opts.GetKey(opts.Config.Universal.NextBlock), Handler: self.SetDirection(snake.Right), }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: self.Escape, }, } return bindings } func (self *SnakeController) Context() types.Context { return self.c.Contexts().Snake } func (self *SnakeController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { self.c.Helpers().Snake.StartGame() } } func (self *SnakeController) GetOnFocusLost() func(types.OnFocusLostOpts) { return func(types.OnFocusLostOpts) { self.c.Helpers().Snake.ExitGame() self.c.Helpers().Window.MoveToTopOfWindow(self.c.Contexts().Submodules) } } func (self *SnakeController) SetDirection(direction snake.Direction) func() error { return func() error { self.c.Helpers().Snake.SetDirection(direction) return nil } } func (self *SnakeController) Escape() error { self.c.Context().Push(self.c.Contexts().Submodules, types.OnFocusOpts{}) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/staging_controller.go000066400000000000000000000220611500612110400240720ustar00rootroot00000000000000package controllers import ( "fmt" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type StagingController struct { baseController c *ControllerCommon context types.IPatchExplorerContext otherContext types.IPatchExplorerContext // if true, we're dealing with the secondary context i.e. dealing with staged file changes staged bool } var _ types.IController = &StagingController{} func NewStagingController( c *ControllerCommon, context types.IPatchExplorerContext, otherContext types.IPatchExplorerContext, staged bool, ) *StagingController { return &StagingController{ baseController: baseController{}, c: c, context: context, otherContext: otherContext, staged: staged, } } func (self *StagingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.ToggleStaged, Description: self.c.Tr.Stage, Tooltip: self.c.Tr.StageSelectionTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.DiscardSelection, Description: self.c.Tr.DiscardSelection, Tooltip: self.c.Tr.DiscardSelectionTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenFile), Handler: self.OpenFile, Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.EditFile, Description: self.c.Tr.EditFile, Tooltip: self.c.Tr.EditFileTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: self.Escape, Description: self.c.Tr.ReturnToFilesPanel, }, { Key: opts.GetKey(opts.Config.Universal.TogglePanel), Handler: self.TogglePanel, Description: self.c.Tr.ToggleStagingView, Tooltip: self.c.Tr.ToggleStagingViewTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Main.EditSelectHunk), Handler: self.EditHunkAndRefresh, Description: self.c.Tr.EditHunk, Tooltip: self.c.Tr.EditHunkTooltip, }, { Key: opts.GetKey(opts.Config.Files.CommitChanges), Handler: self.c.Helpers().WorkingTree.HandleCommitPress, Description: self.c.Tr.Commit, Tooltip: self.c.Tr.CommitTooltip, }, { Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook), Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress, Description: self.c.Tr.CommitChangesWithoutHook, }, { Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor), Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress, Description: self.c.Tr.CommitChangesWithEditor, }, { Key: opts.GetKey(opts.Config.Files.FindBaseCommitForFixup), Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress, Description: self.c.Tr.FindBaseCommitForFixup, Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip, }, } } func (self *StagingController) Context() types.Context { return self.context } func (self *StagingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{} } func (self *StagingController) GetOnFocus() func(types.OnFocusOpts) { return func(opts types.OnFocusOpts) { wrap := self.c.UserConfig().Gui.WrapLinesInStagingView self.c.Views().Staging.Wrap = wrap self.c.Views().StagingSecondary.Wrap = wrap self.c.Helpers().Staging.RefreshStagingPanel(opts) } } func (self *StagingController) GetOnFocusLost() func(types.OnFocusLostOpts) { return func(opts types.OnFocusLostOpts) { self.context.SetState(nil) if opts.NewContextKey != self.otherContext.GetKey() { self.c.Views().Staging.Wrap = true self.c.Views().StagingSecondary.Wrap = true } } } func (self *StagingController) OpenFile() error { self.context.GetMutex().Lock() defer self.context.GetMutex().Unlock() path := self.FilePath() if path == "" { return nil } return self.c.Helpers().Files.OpenFile(path) } func (self *StagingController) EditFile() error { self.context.GetMutex().Lock() defer self.context.GetMutex().Unlock() path := self.FilePath() if path == "" { return nil } lineNumber := self.context.GetState().CurrentLineNumber() lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context.GetViewName()) return self.c.Helpers().Files.EditFileAtLine(path, lineNumber) } func (self *StagingController) Escape() error { if self.context.GetState().SelectingRange() || self.context.GetState().SelectingHunk() { self.context.GetState().SetLineSelectMode() self.c.PostRefreshUpdate(self.context) return nil } self.c.Context().Pop() return nil } func (self *StagingController) TogglePanel() error { if self.otherContext.GetState() != nil { self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) } return nil } func (self *StagingController) ToggleStaged() error { if self.c.AppState.DiffContextSize == 0 { return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage, keybindings.Label(self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)) } return self.applySelectionAndRefresh(self.staged) } func (self *StagingController) DiscardSelection() error { if self.c.AppState.DiffContextSize == 0 { return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard, keybindings.Label(self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)) } reset := func() error { return self.applySelectionAndRefresh(true) } if !self.staged && !self.c.UserConfig().Gui.SkipDiscardChangeWarning { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.DiscardChangeTitle, Prompt: self.c.Tr.DiscardChangePrompt, HandleConfirm: reset, }) return nil } return reset() } func (self *StagingController) applySelectionAndRefresh(reverse bool) error { if err := self.applySelection(reverse); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) } func (self *StagingController) applySelection(reverse bool) error { self.context.GetMutex().Lock() defer self.context.GetMutex().Unlock() state := self.context.GetState() path := self.FilePath() if path == "" { return nil } firstLineIdx, lastLineIdx := state.SelectedPatchRange() patchToApply := patch. Parse(state.GetDiff()). Transform(patch.TransformOpts{ Reverse: reverse, IncludedLineIndices: patch.ExpandRange(firstLineIdx, lastLineIdx), FileNameOverride: path, }). FormatPlain() if patchToApply == "" { return nil } // apply the patch then refresh this panel // create a new temp file with the patch, then call git apply with that patch self.c.LogAction(self.c.Tr.Actions.ApplyPatch) err := self.c.Git().Patch.ApplyPatch( patchToApply, git_commands.ApplyPatchOpts{ Reverse: reverse, Cached: !reverse || self.staged, }, ) if err != nil { return err } if state.SelectingRange() { firstLine, _ := state.SelectedViewRange() state.SelectLine(firstLine) } return nil } func (self *StagingController) EditHunkAndRefresh() error { if err := self.editHunk(); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) } func (self *StagingController) editHunk() error { self.context.GetMutex().Lock() defer self.context.GetMutex().Unlock() state := self.context.GetState() path := self.FilePath() if path == "" { return nil } hunkStartIdx, hunkEndIdx := state.CurrentHunkBounds() patchText := patch. Parse(state.GetDiff()). Transform(patch.TransformOpts{ Reverse: self.staged, IncludedLineIndices: patch.ExpandRange(hunkStartIdx, hunkEndIdx), FileNameOverride: path, }). FormatPlain() patchFilepath, err := self.c.Git().Patch.SaveTemporaryPatch(patchText) if err != nil { return err } lineOffset := 3 lineIdxInHunk := state.GetSelectedPatchLineIdx() - hunkStartIdx if err := self.c.Helpers().Files.EditFileAtLineAndWait(patchFilepath, lineIdxInHunk+lineOffset); err != nil { return err } editedPatchText, err := self.c.Git().File.Cat(patchFilepath) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.ApplyPatch) lineCount := strings.Count(editedPatchText, "\n") + 1 newPatchText := patch. Parse(editedPatchText). Transform(patch.TransformOpts{ IncludedLineIndices: patch.ExpandRange(0, lineCount), FileNameOverride: path, }). FormatPlain() if err := self.c.Git().Patch.ApplyPatch( newPatchText, git_commands.ApplyPatchOpts{ Reverse: self.staged, Cached: true, }, ); err != nil { return err } return nil } func (self *StagingController) FilePath() string { return self.c.Contexts().Files.GetSelectedPath() } lazygit-0.50.0+ds1/pkg/gui/controllers/stash_controller.go000066400000000000000000000140531500612110400235620ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type StashController struct { baseController *ListControllerTrait[*models.StashEntry] c *ControllerCommon } var _ types.IController = &StashController{} func NewStashController( c *ControllerCommon, ) *StashController { return &StashController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Stash, c.Contexts().Stash.GetSelected, c.Contexts().Stash.GetSelectedItems, ), c: c, } } func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItem(self.handleStashApply), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Apply, Tooltip: self.c.Tr.StashApplyTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Stash.PopStash), Handler: self.withItem(self.handleStashPop), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Pop, Tooltip: self.c.Tr.StashPopTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItems(self.handleStashDrop), GetDisabledReason: self.require(self.itemRangeSelected()), Description: self.c.Tr.Drop, Tooltip: self.c.Tr.StashDropTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.New), Handler: self.withItem(self.handleNewBranchOffStashEntry), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewBranch, Tooltip: self.c.Tr.NewBranchFromStashTooltip, }, { Key: opts.GetKey(opts.Config.Stash.RenameStash), Handler: self.withItem(self.handleRenameStashEntry), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.RenameStash, }, } return bindings } func (self *StashController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { var task types.UpdateTask stashEntry := self.context().GetSelected() if stashEntry == nil { task = types.NewRenderStringTask(self.c.Tr.NoStashEntries) } else { task = types.NewRunPtyTask( self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(), ) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Stash", SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: task, }, }) }) } } func (self *StashController) context() *context.StashContext { return self.c.Contexts().Stash } func (self *StashController) handleStashApply(stashEntry *models.StashEntry) error { apply := func() error { self.c.LogAction(self.c.Tr.Actions.Stash) err := self.c.Git().Stash.Apply(stashEntry.Index) _ = self.postStashRefresh() if err != nil { return err } if self.c.UserConfig().Gui.SwitchToFilesAfterStashApply { self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } return nil } if self.c.UserConfig().Gui.SkipStashWarning { return apply() } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.StashApply, Prompt: self.c.Tr.SureApplyStashEntry, HandleConfirm: func() error { return apply() }, }) return nil } func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error { pop := func() error { self.c.LogAction(self.c.Tr.Actions.Stash) err := self.c.Git().Stash.Pop(stashEntry.Index) _ = self.postStashRefresh() if err != nil { return err } if self.c.UserConfig().Gui.SwitchToFilesAfterStashPop { self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } return nil } if self.c.UserConfig().Gui.SkipStashWarning { return pop() } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.StashPop, Prompt: self.c.Tr.SurePopStashEntry, HandleConfirm: func() error { return pop() }, }) return nil } func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.StashDrop, Prompt: self.c.Tr.SureDropStashEntry, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.Stash) startIndex := stashEntries[0].Index for range stashEntries { err := self.c.Git().Stash.Drop(startIndex) _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) if err != nil { return err } } return nil }, }) return nil } func (self *StashController) postStashRefresh() error { return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) } func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error { return self.c.Helpers().Refs.NewBranch(stashEntry.RefName(), stashEntry.Description(), "") } func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntry) error { message := utils.ResolvePlaceholderString( self.c.Tr.RenameStashPrompt, map[string]string{ "stashName": stashEntry.RefName(), }, ) self.c.Prompt(types.PromptOpts{ Title: message, InitialContent: stashEntry.Name, HandleConfirm: func(response string) error { self.c.LogAction(self.c.Tr.Actions.RenameStash) err := self.c.Git().Stash.Rename(stashEntry.Index, response) if err != nil { _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) return err } self.context().SetSelection(0) // Select the renamed stash self.context().FocusLine() return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) }, }) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/status_controller.go000066400000000000000000000151311500612110400237610ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "strings" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type StatusController struct { baseController c *ControllerCommon } var _ types.IController = &StatusController{} func NewStatusController( c *ControllerCommon, ) *StatusController { return &StatusController{ baseController: baseController{}, c: c, } } func (self *StatusController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.OpenFile), Handler: self.openConfig, Description: self.c.Tr.OpenConfig, Tooltip: self.c.Tr.OpenFileTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.editConfig, Description: self.c.Tr.EditConfig, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Status.CheckForUpdate), Handler: self.handleCheckForUpdate, Description: self.c.Tr.CheckForUpdate, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Status.RecentRepos), Handler: self.c.Helpers().Repos.CreateRecentReposMenu, Description: self.c.Tr.SwitchRepo, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Status.AllBranchesLogGraph), Handler: func() error { self.showAllBranchLogs(); return nil }, Description: self.c.Tr.AllBranchesLogGraph, }, } return bindings } func (self *StatusController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.Context().GetViewName(), Key: gocui.MouseLeft, Handler: self.onClick, }, } } func (self *StatusController) GetOnRenderToMain() func() { return func() { switch self.c.UserConfig().Gui.StatusPanelView { case "dashboard": self.showDashboard() case "allBranchesLog": self.showAllBranchLogs() default: self.showDashboard() } } } func (self *StatusController) Context() types.Context { return self.c.Contexts().Status } func (self *StatusController) onClick(opts gocui.ViewMouseBindingOpts) error { // TODO: move into some abstraction (status is currently not a listViewContext where a lot of this code lives) currentBranch := self.c.Helpers().Refs.GetCheckedOutRef() if currentBranch == nil { // need to wait for branches to refresh return nil } self.c.Context().Push(self.Context(), types.OnFocusOpts{}) upstreamStatus := utils.Decolorise(presentation.BranchStatus(currentBranch, types.ItemOperationNone, self.c.Tr, time.Now(), self.c.UserConfig())) repoName := self.c.Git().RepoPaths.RepoName() workingTreeState := self.c.Git().Status.WorkingTreeState() if workingTreeState.Any() { workingTreeStatus := fmt.Sprintf("(%s)", workingTreeState.LowerCaseTitle(self.c.Tr)) if cursorInSubstring(opts.X, upstreamStatus+" ", workingTreeStatus) { return self.c.Helpers().MergeAndRebase.CreateRebaseOptionsMenu() } if cursorInSubstring(opts.X, upstreamStatus+" "+workingTreeStatus+" ", repoName) { return self.c.Helpers().Repos.CreateRecentReposMenu() } } else if cursorInSubstring(opts.X, upstreamStatus+" ", repoName) { return self.c.Helpers().Repos.CreateRecentReposMenu() } return nil } func runeCount(str string) int { return len([]rune(str)) } func cursorInSubstring(cx int, prefix string, substring string) bool { return cx >= runeCount(prefix) && cx < runeCount(prefix+substring) } func lazygitTitle() string { return ` _ _ _ | | (_) | | | __ _ _____ _ __ _ _| |_ | |/ _` + "`" + ` |_ / | | |/ _` + "`" + ` | | __| | | (_| |/ /| |_| | (_| | | |_ |_|\__,_/___|\__, |\__, |_|\__| __/ | __/ | |___/ |___/ ` } func (self *StatusController) askForConfigFile(action func(file string) error) error { confPaths := self.c.GetConfig().GetUserConfigPaths() switch len(confPaths) { case 0: return errors.New(self.c.Tr.NoConfigFileFoundErr) case 1: return action(confPaths[0]) default: menuItems := lo.Map(confPaths, func(path string, _ int) *types.MenuItem { return &types.MenuItem{ Label: path, OnPress: func() error { return action(path) }, } }) return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.SelectConfigFile, Items: menuItems, }) } } func (self *StatusController) openConfig() error { return self.askForConfigFile(self.c.Helpers().Files.OpenFile) } func (self *StatusController) editConfig() error { return self.askForConfigFile(func(file string) error { return self.c.Helpers().Files.EditFiles([]string{file}) }) } func (self *StatusController) showAllBranchLogs() { cmdObj := self.c.Git().Branch.AllBranchesLogCmdObj() task := types.NewRunPtyTask(cmdObj.GetCmd()) self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: self.c.Tr.LogTitle, Task: task, }, }) } func (self *StatusController) showDashboard() { versionStr := "master" version, err := types.ParseVersionNumber(self.c.GetConfig().GetVersion()) if err == nil { // Don't just take the version string as is, but format it again. This // way it will be correct even if a distribution omits the "v", or the // ".0" at the end. versionStr = fmt.Sprintf("v%d.%d.%d", version.Major, version.Minor, version.Patch) } dashboardString := strings.Join( []string{ lazygitTitle(), fmt.Sprintf("Copyright %d Jesse Duffield", time.Now().Year()), fmt.Sprintf("Keybindings: %s", fmt.Sprintf(constants.Links.Docs.Keybindings, versionStr)), fmt.Sprintf("Config Options: %s", fmt.Sprintf(constants.Links.Docs.Config, versionStr)), fmt.Sprintf("Tutorial: %s", constants.Links.Docs.Tutorial), fmt.Sprintf("Raise an Issue: %s", constants.Links.Issues), fmt.Sprintf("Release Notes: %s", constants.Links.Releases), style.FgMagenta.Sprintf("Become a sponsor: %s", constants.Links.Donate), // caffeine ain't free }, "\n\n") + "\n" self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: self.c.Tr.StatusTitle, Task: types.NewRenderStringTask(dashboardString), }, }) } func (self *StatusController) handleCheckForUpdate() error { return self.c.Helpers().Update.CheckForUpdateInForeground() } lazygit-0.50.0+ds1/pkg/gui/controllers/sub_commits_controller.go000066400000000000000000000037671500612110400247760ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type SubCommitsController struct { baseController *ListControllerTrait[*models.Commit] c *ControllerCommon } var _ types.IController = &SubCommitsController{} func NewSubCommitsController( c *ControllerCommon, ) *SubCommitsController { return &SubCommitsController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().SubCommits, c.Contexts().SubCommits.GetSelected, c.Contexts().SubCommits.GetSelectedItems, ), c: c, } } func (self *SubCommitsController) Context() types.Context { return self.context() } func (self *SubCommitsController) context() *context.SubCommitsContext { return self.c.Contexts().SubCommits } func (self *SubCommitsController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { commit := self.context().GetSelected() var task types.UpdateTask if commit == nil { task = types.NewRenderStringTask("No commits") } else { refRange := self.context().GetSelectedRefRangeForDiffFiles() task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Commit", SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: task, }, }) }) } } func (self *SubCommitsController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { context := self.context() if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() { context.SetLimitCommits(false) self.c.OnWorker(func(_ gocui.Task) error { return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUB_COMMITS}}) }) } } } lazygit-0.50.0+ds1/pkg/gui/controllers/submodules_controller.go000066400000000000000000000243361500612110400246270ustar00rootroot00000000000000package controllers import ( "fmt" "path/filepath" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type SubmodulesController struct { baseController *ListControllerTrait[*models.SubmoduleConfig] c *ControllerCommon } var _ types.IController = &SubmodulesController{} func NewSubmodulesController( c *ControllerCommon, ) *SubmodulesController { return &SubmodulesController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Submodules, c.Contexts().Submodules.GetSelected, c.Contexts().Submodules.GetSelectedItems, ), c: c, } } func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.GoInto), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Enter, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.EnterSubmoduleTooltip, map[string]string{"escape": keybindings.Label(opts.Config.Universal.Return)}), DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItem(self.remove), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Remove, Tooltip: self.c.Tr.RemoveSubmoduleTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Submodules.Update), Handler: self.withItem(self.update), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Update, Tooltip: self.c.Tr.SubmoduleUpdateTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.New), Handler: self.add, Description: self.c.Tr.NewSubmodule, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.withItem(self.editURL), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.EditSubmoduleUrl, }, { Key: opts.GetKey(opts.Config.Submodules.Init), Handler: self.withItem(self.init), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Initialize, Tooltip: self.c.Tr.InitSubmoduleTooltip, }, { Key: opts.GetKey(opts.Config.Submodules.BulkMenu), Handler: self.openBulkActionsMenu, Description: self.c.Tr.ViewBulkSubmoduleOptions, OpensMenu: true, }, { Key: nil, Handler: self.easterEgg, Description: self.c.Tr.EasterEgg, }, } } func (self *SubmodulesController) GetOnClick() func() error { return self.withItemGraceful(self.enter) } func (self *SubmodulesController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { var task types.UpdateTask submodule := self.context().GetSelected() if submodule == nil { task = types.NewRenderStringTask("No submodules") } else { prefix := fmt.Sprintf( "Name: %s\nPath: %s\nUrl: %s\n\n", style.FgGreen.Sprint(submodule.FullName()), style.FgYellow.Sprint(submodule.FullPath()), style.FgCyan.Sprint(submodule.Url), ) file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) if file == nil { task = types.NewRenderStringTask(prefix) } else { cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges) task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix) } } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Submodule", Task: task, }, }) }) } } func (self *SubmodulesController) enter(submodule *models.SubmoduleConfig) error { return self.c.Helpers().Repos.EnterSubmodule(submodule) } func (self *SubmodulesController) add() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewSubmoduleUrl, HandleConfirm: func(submoduleUrl string) error { nameSuggestion := filepath.Base(strings.TrimSuffix(submoduleUrl, filepath.Ext(submoduleUrl))) self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewSubmoduleName, InitialContent: nameSuggestion, HandleConfirm: func(submoduleName string) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewSubmodulePath, InitialContent: submoduleName, HandleConfirm: func(submodulePath string) error { return self.c.WithWaitingStatus(self.c.Tr.AddingSubmoduleStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddSubmodule) err := self.c.Git().Submodule.Add(submoduleName, submodulePath, submoduleUrl) if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) }) }, }) return nil }, }) return nil }, }) return nil } func (self *SubmodulesController) editURL(submodule *models.SubmoduleConfig) error { self.c.Prompt(types.PromptOpts{ Title: fmt.Sprintf(self.c.Tr.UpdateSubmoduleUrl, submodule.FullName()), InitialContent: submodule.Url, HandleConfirm: func(newUrl string) error { return self.c.WithWaitingStatus(self.c.Tr.UpdatingSubmoduleUrlStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.UpdateSubmoduleUrl) err := self.c.Git().Submodule.UpdateUrl(submodule, newUrl) if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) }) }, }) return nil } func (self *SubmodulesController) init(submodule *models.SubmoduleConfig) error { return self.c.WithWaitingStatus(self.c.Tr.InitializingSubmoduleStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.InitialiseSubmodule) err := self.c.Git().Submodule.Init(submodule.Path) if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) }) } func (self *SubmodulesController) openBulkActionsMenu() error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.BulkSubmoduleOptions, Items: []*types.MenuItem{ { LabelColumns: []string{self.c.Tr.BulkInitSubmodules, style.FgGreen.Sprint(self.c.Git().Submodule.BulkInitCmdObj().ToString())}, OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.RunningCommand, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.BulkInitialiseSubmodules) err := self.c.Git().Submodule.BulkInitCmdObj().Run() if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) }) }, Key: 'i', }, { LabelColumns: []string{self.c.Tr.BulkUpdateSubmodules, style.FgYellow.Sprint(self.c.Git().Submodule.BulkUpdateCmdObj().ToString())}, OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.RunningCommand, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.BulkUpdateSubmodules) if err := self.c.Git().Submodule.BulkUpdateCmdObj().Run(); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) }) }, Key: 'u', }, { LabelColumns: []string{self.c.Tr.BulkUpdateRecursiveSubmodules, style.FgYellow.Sprint(self.c.Git().Submodule.BulkUpdateRecursivelyCmdObj().ToString())}, OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.RunningCommand, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.BulkUpdateRecursiveSubmodules) if err := self.c.Git().Submodule.BulkUpdateRecursivelyCmdObj().Run(); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) }) }, Key: 'r', }, { LabelColumns: []string{self.c.Tr.BulkDeinitSubmodules, style.FgRed.Sprint(self.c.Git().Submodule.BulkDeinitCmdObj().ToString())}, OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.RunningCommand, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.BulkDeinitialiseSubmodules) if err := self.c.Git().Submodule.BulkDeinitCmdObj().Run(); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) }) }, Key: 'd', }, }, }) } func (self *SubmodulesController) update(submodule *models.SubmoduleConfig) error { return self.c.WithWaitingStatus(self.c.Tr.UpdatingSubmoduleStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.UpdateSubmodule) err := self.c.Git().Submodule.Update(submodule.Path) if err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) }) } func (self *SubmodulesController) remove(submodule *models.SubmoduleConfig) error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.RemoveSubmodule, Prompt: fmt.Sprintf(self.c.Tr.RemoveSubmodulePrompt, submodule.FullName()), HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RemoveSubmodule) if err := self.c.Git().Submodule.Delete(submodule); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES, types.FILES}}) }, }) return nil } func (self *SubmodulesController) easterEgg() error { self.c.Context().Push(self.c.Contexts().Snake, types.OnFocusOpts{}) return nil } func (self *SubmodulesController) context() *context.SubmodulesContext { return self.c.Contexts().Submodules } lazygit-0.50.0+ds1/pkg/gui/controllers/suggestions_controller.go000066400000000000000000000047041500612110400250140ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type SuggestionsController struct { baseController *ListControllerTrait[*types.Suggestion] c *ControllerCommon } var _ types.IController = &SuggestionsController{} func NewSuggestionsController( c *ControllerCommon, ) *SuggestionsController { return &SuggestionsController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Suggestions, c.Contexts().Suggestions.GetSelected, c.Contexts().Suggestions.GetSelectedItems, ), c: c, } } func (self *SuggestionsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Confirm), Handler: func() error { return self.context().State.OnConfirm() }, GetDisabledReason: self.require(self.singleItemSelected()), }, { Key: opts.GetKey(opts.Config.Universal.Return), Handler: func() error { return self.context().State.OnClose() }, }, { Key: opts.GetKey(opts.Config.Universal.TogglePanel), Handler: self.switchToConfirmation, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: func() error { return self.context().State.OnDeleteSuggestion() }, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: func() error { if self.context().State.AllowEditSuggestion { if selectedItem := self.c.Contexts().Suggestions.GetSelected(); selectedItem != nil { self.c.Contexts().Confirmation.GetView().TextArea.Clear() self.c.Contexts().Confirmation.GetView().TextArea.TypeString(selectedItem.Value) self.c.Contexts().Confirmation.GetView().RenderTextArea() self.c.Contexts().Suggestions.RefreshSuggestions() return self.switchToConfirmation() } } return nil }, }, } return bindings } func (self *SuggestionsController) switchToConfirmation() error { self.c.Views().Suggestions.Subtitle = "" self.c.Views().Suggestions.Highlight = false self.c.Context().Replace(self.c.Contexts().Confirmation) return nil } func (self *SuggestionsController) GetOnFocusLost() func(types.OnFocusLostOpts) { return func(types.OnFocusLostOpts) { self.c.Helpers().Confirmation.DeactivateConfirmationPrompt() } } func (self *SuggestionsController) context() *context.SuggestionsContext { return self.c.Contexts().Suggestions } lazygit-0.50.0+ds1/pkg/gui/controllers/switch_to_diff_files_controller.go000066400000000000000000000055531500612110400266220ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/types" ) // This controller is for all contexts that contain commit files. var _ types.IController = &SwitchToDiffFilesController{} type CanSwitchToDiffFiles interface { types.IListContext CanRebase() bool GetSelectedRef() types.Ref GetSelectedRefRangeForDiffFiles() *types.RefRange } // Not using our ListControllerTrait because we have our own way of working with // range selections that's different from ListControllerTrait's type SwitchToDiffFilesController struct { baseController c *ControllerCommon context CanSwitchToDiffFiles } func NewSwitchToDiffFilesController( c *ControllerCommon, context CanSwitchToDiffFiles, ) *SwitchToDiffFilesController { return &SwitchToDiffFilesController{ baseController: baseController{}, c: c, context: context, } } func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.GoInto), Handler: self.enter, GetDisabledReason: self.canEnter, Description: self.c.Tr.ViewItemFiles, }, } return bindings } func (self *SwitchToDiffFilesController) Context() types.Context { return self.context } func (self *SwitchToDiffFilesController) GetOnClick() func() error { return func() error { if self.canEnter() == nil { return self.enter() } return nil } } func (self *SwitchToDiffFilesController) enter() error { ref := self.context.GetSelectedRef() refsRange := self.context.GetSelectedRefRangeForDiffFiles() commitFilesContext := self.c.Contexts().CommitFiles canRebase := self.context.CanRebase() if canRebase { if self.c.Modes().Diffing.Active() { if self.c.Modes().Diffing.Ref != ref.RefName() { canRebase = false } } else if refsRange != nil { canRebase = false } } commitFilesContext.ReInit(ref, refsRange) commitFilesContext.SetSelection(0) commitFilesContext.SetCanRebase(canRebase) commitFilesContext.SetParentContext(self.context) commitFilesContext.SetWindowName(self.context.GetWindowName()) commitFilesContext.ClearSearchString() commitFilesContext.GetView().TitlePrefix = self.context.GetView().TitlePrefix if err := self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.COMMIT_FILES}, }); err != nil { return err } self.c.Context().Push(commitFilesContext, types.OnFocusOpts{}) return nil } func (self *SwitchToDiffFilesController) canEnter() *types.DisabledReason { refRange := self.context.GetSelectedRefRangeForDiffFiles() if refRange != nil { return nil } ref := self.context.GetSelectedRef() if ref == nil { return &types.DisabledReason{Text: self.c.Tr.NoItemSelected} } if ref.RefName() == "" { return &types.DisabledReason{Text: self.c.Tr.SelectedItemDoesNotHaveFiles} } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/switch_to_focused_main_view_controller.go000066400000000000000000000044021500612110400302060ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) // This controller is for all contexts that can focus their main view. var _ types.IController = &SwitchToFocusedMainViewController{} type SwitchToFocusedMainViewController struct { baseController c *ControllerCommon context types.Context } func NewSwitchToFocusedMainViewController( c *ControllerCommon, context types.Context, ) *SwitchToFocusedMainViewController { return &SwitchToFocusedMainViewController{ baseController: baseController{}, c: c, context: context, } } func (self *SwitchToFocusedMainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.FocusMainView), Handler: self.handleFocusMainView, Description: self.c.Tr.FocusMainView, }, } return bindings } func (self *SwitchToFocusedMainViewController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: "main", Key: gocui.MouseLeft, Handler: self.onClickMain, FocusedView: self.context.GetViewName(), }, { ViewName: "secondary", Key: gocui.MouseLeft, Handler: self.onClickSecondary, FocusedView: self.context.GetViewName(), }, } } func (self *SwitchToFocusedMainViewController) Context() types.Context { return self.context } func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error { return self.focusMainView("main") } func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error { return self.focusMainView("secondary") } func (self *SwitchToFocusedMainViewController) handleFocusMainView() error { return self.focusMainView("main") } func (self *SwitchToFocusedMainViewController) focusMainView(mainViewName string) error { mainViewContext := self.c.Helpers().Window.GetContextForWindow(mainViewName) mainViewContext.SetParentContext(self.context) if context, ok := mainViewContext.(types.ISearchableContext); ok { context.ClearSearchString() } self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/switch_to_sub_commits_controller.go000066400000000000000000000035331500612110400270500ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) var _ types.IController = &SwitchToSubCommitsController{} type CanSwitchToSubCommits interface { types.IListContext GetSelectedRef() types.Ref ShowBranchHeadsInSubCommits() bool } // Not using our ListControllerTrait because our 'selected' item is not a list item // but an attribute on it i.e. the ref of an item. type SwitchToSubCommitsController struct { baseController *ListControllerTrait[types.Ref] c *ControllerCommon context CanSwitchToSubCommits } func NewSwitchToSubCommitsController( c *ControllerCommon, context CanSwitchToSubCommits, ) *SwitchToSubCommitsController { return &SwitchToSubCommitsController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, context, context.GetSelectedRef, func() ([]types.Ref, int, int) { panic("Not implemented") }, ), c: c, context: context, } } func (self *SwitchToSubCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Handler: self.viewCommits, GetDisabledReason: self.require(self.singleItemSelected()), Key: opts.GetKey(opts.Config.Universal.GoInto), Description: self.c.Tr.ViewCommits, }, } return bindings } func (self *SwitchToSubCommitsController) GetOnClick() func() error { return self.viewCommits } func (self *SwitchToSubCommitsController) viewCommits() error { ref := self.context.GetSelectedRef() if ref == nil { return nil } return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{ Ref: ref, TitleRef: ref.RefName(), Context: self.context, ShowBranchHeads: self.context.ShowBranchHeadsInSubCommits(), }) } lazygit-0.50.0+ds1/pkg/gui/controllers/sync_controller.go000066400000000000000000000171361500612110400234210ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type SyncController struct { baseController c *ControllerCommon } var _ types.IController = &SyncController{} func NewSyncController( common *ControllerCommon, ) *SyncController { return &SyncController{ baseController: baseController{}, c: common, } } func (self *SyncController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Push), Handler: opts.Guards.NoPopupPanel(self.HandlePush), GetDisabledReason: self.getDisabledReasonForPushOrPull, Description: self.c.Tr.Push, Tooltip: self.c.Tr.PushTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Pull), Handler: opts.Guards.NoPopupPanel(self.HandlePull), GetDisabledReason: self.getDisabledReasonForPushOrPull, Description: self.c.Tr.Pull, Tooltip: self.c.Tr.PullTooltip, }, } return bindings } func (self *SyncController) Context() types.Context { return nil } func (self *SyncController) HandlePush() error { return self.branchCheckedOut(self.push)() } func (self *SyncController) HandlePull() error { return self.branchCheckedOut(self.pull)() } func (self *SyncController) getDisabledReasonForPushOrPull() *types.DisabledReason { currentBranch := self.c.Helpers().Refs.GetCheckedOutRef() if currentBranch != nil { op := self.c.State().GetItemOperation(currentBranch) if op != types.ItemOperationNone { return &types.DisabledReason{Text: self.c.Tr.CantPullOrPushSameBranchTwice} } } return nil } func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func() error { return func() error { currentBranch := self.c.Helpers().Refs.GetCheckedOutRef() if currentBranch == nil { // need to wait for branches to refresh return nil } return f(currentBranch) } } func (self *SyncController) push(currentBranch *models.Branch) error { // if we are behind our upstream branch we'll ask if the user wants to force push if currentBranch.IsTrackingRemote() { opts := pushOpts{remoteBranchStoredLocally: currentBranch.RemoteBranchStoredLocally()} if currentBranch.IsBehindForPush() { return self.requestToForcePush(currentBranch, opts) } else { return self.pushAux(currentBranch, opts) } } else { if self.c.Git().Config.GetPushToCurrent() { return self.pushAux(currentBranch, pushOpts{setUpstream: true}) } else { return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error { upstreamRemote, upstreamBranch, err := self.c.Helpers().Upstream.ParseUpstream(upstream) if err != nil { return err } return self.pushAux(currentBranch, pushOpts{ setUpstream: true, upstreamRemote: upstreamRemote, upstreamBranch: upstreamBranch, }) }) } } } func (self *SyncController) pull(currentBranch *models.Branch) error { action := self.c.Tr.Actions.Pull // if we have no upstream branch we need to set that first if !currentBranch.IsTrackingRemote() { return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error { if err := self.setCurrentBranchUpstream(upstream); err != nil { return err } return self.PullAux(currentBranch, PullFilesOptions{Action: action}) }) } return self.PullAux(currentBranch, PullFilesOptions{Action: action}) } func (self *SyncController) setCurrentBranchUpstream(upstream string) error { upstreamRemote, upstreamBranch, err := self.c.Helpers().Upstream.ParseUpstream(upstream) if err != nil { return err } if err := self.c.Git().Branch.SetCurrentBranchUpstream(upstreamRemote, upstreamBranch); err != nil { if strings.Contains(err.Error(), "does not exist") { return fmt.Errorf( "upstream branch %s/%s not found.\nIf you expect it to exist, you should fetch (with 'f').\nOtherwise, you should push (with 'shift+P')", upstreamRemote, upstreamBranch, ) } return err } return nil } type PullFilesOptions struct { UpstreamRemote string UpstreamBranch string FastForwardOnly bool Action string } func (self *SyncController) PullAux(currentBranch *models.Branch, opts PullFilesOptions) error { return self.c.WithInlineStatus(currentBranch, types.ItemOperationPulling, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { return self.pullWithLock(task, opts) }) } func (self *SyncController) pullWithLock(task gocui.Task, opts PullFilesOptions) error { self.c.LogAction(opts.Action) err := self.c.Git().Sync.Pull( task, git_commands.PullOptions{ RemoteName: opts.UpstreamRemote, BranchName: opts.UpstreamBranch, FastForwardOnly: opts.FastForwardOnly, }, ) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } type pushOpts struct { force bool forceWithLease bool upstreamRemote string upstreamBranch string setUpstream bool // If this is false, we can't tell ahead of time whether a force-push will // be necessary, so we start with a normal push and offer to force-push if // the server rejected. If this is true, we don't offer to force-push if the // server rejected, but rather ask the user to fetch. remoteBranchStoredLocally bool } func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) error { return self.c.WithInlineStatus(currentBranch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.Push) err := self.c.Git().Sync.Push( task, git_commands.PushOpts{ Force: opts.force, ForceWithLease: opts.forceWithLease, CurrentBranch: currentBranch.Name, UpstreamRemote: opts.upstreamRemote, UpstreamBranch: opts.upstreamBranch, SetUpstream: opts.setUpstream, }) if err != nil { if !opts.force && !opts.forceWithLease && strings.Contains(err.Error(), "Updates were rejected") { if opts.remoteBranchStoredLocally { return errors.New(self.c.Tr.UpdatesRejected) } forcePushDisabled := self.c.UserConfig().Git.DisableForcePushing if forcePushDisabled { return errors.New(self.c.Tr.UpdatesRejectedAndForcePushDisabled) } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.ForcePush, Prompt: self.forcePushPrompt(), HandleConfirm: func() error { newOpts := opts newOpts.force = true return self.pushAux(currentBranch, newOpts) }, }) return nil } return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }) } func (self *SyncController) requestToForcePush(currentBranch *models.Branch, opts pushOpts) error { forcePushDisabled := self.c.UserConfig().Git.DisableForcePushing if forcePushDisabled { return errors.New(self.c.Tr.ForcePushDisabled) } self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.ForcePush, Prompt: self.forcePushPrompt(), HandleConfirm: func() error { opts.forceWithLease = true return self.pushAux(currentBranch, opts) }, }) return nil } func (self *SyncController) forcePushPrompt() string { return utils.ResolvePlaceholderString( self.c.Tr.ForcePushPrompt, map[string]string{ "cancelKey": self.c.UserConfig().Keybinding.Universal.Return, "confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm, }, ) } lazygit-0.50.0+ds1/pkg/gui/controllers/tags_controller.go000066400000000000000000000214141500612110400233750ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) type TagsController struct { baseController *ListControllerTrait[*models.Tag] c *ControllerCommon } var _ types.IController = &TagsController{} func NewTagsController( c *ControllerCommon, ) *TagsController { return &TagsController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Tags, c.Contexts().Tags.GetSelected, c.Contexts().Tags.GetSelectedItems, ), c: c, } } func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItem(self.checkout), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Checkout, Tooltip: self.c.Tr.TagCheckoutTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.New), Handler: self.create, Description: self.c.Tr.NewTag, Tooltip: self.c.Tr.NewTagTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItem(self.delete), Description: self.c.Tr.Delete, GetDisabledReason: self.require(self.singleItemSelected()), Tooltip: self.c.Tr.TagDeleteTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Branches.PushTag), Handler: self.withItem(self.push), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.PushTag, Tooltip: self.c.Tr.PushTagTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Reset, Tooltip: self.c.Tr.ResetTooltip, DisplayOnScreen: true, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(func(selectedTag *models.Tag) error { return self.c.Helpers().Diff.OpenDiffToolForRef(selectedTag) }), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, } return bindings } func (self *TagsController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { var task types.UpdateTask tag := self.context().GetSelected() if tag == nil { task = types.NewRenderStringTask("No tags") } else { cmdObj := self.c.Git().Branch.GetGraphCmdObj(tag.FullRefName()) task = types.NewRunCommandTask(cmdObj.GetCmd()) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Tag", Task: task, }, }) }) } } func (self *TagsController) checkout(tag *models.Tag) error { self.c.LogAction(self.c.Tr.Actions.CheckoutTag) if err := self.c.Helpers().Refs.CheckoutRef(tag.FullRefName(), types.CheckoutRefOptions{}); err != nil { return err } self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) return nil } func (self *TagsController) localDelete(tag *models.Tag) error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) err := self.c.Git().Tag.LocalDelete(tag.Name) _ = self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return err }) } func (self *TagsController) remoteDelete(tag *models.Tag) error { title := utils.ResolvePlaceholderString( self.c.Tr.SelectRemoteTagUpstream, map[string]string{ "tagName": tag.Name, }, ) self.c.Prompt(types.PromptOpts{ Title: title, InitialContent: "origin", FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(), HandleConfirm: func(upstream string) error { confirmTitle := utils.ResolvePlaceholderString( self.c.Tr.DeleteTagTitle, map[string]string{ "tagName": tag.Name, }, ) confirmPrompt := utils.ResolvePlaceholderString( self.c.Tr.DeleteRemoteTagPrompt, map[string]string{ "tagName": tag.Name, "upstream": upstream, }, ) self.c.Confirm(types.ConfirmOpts{ Title: confirmTitle, Prompt: confirmPrompt, HandleConfirm: func() error { return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag) if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tag.Name); err != nil { return err } self.c.Toast(self.c.Tr.RemoteTagDeletedMessage) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) }) }, }) return nil }, }) return nil } func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error { title := utils.ResolvePlaceholderString( self.c.Tr.SelectRemoteTagUpstream, map[string]string{ "tagName": tag.Name, }, ) self.c.Prompt(types.PromptOpts{ Title: title, InitialContent: "origin", FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(), HandleConfirm: func(upstream string) error { confirmTitle := utils.ResolvePlaceholderString( self.c.Tr.DeleteTagTitle, map[string]string{ "tagName": tag.Name, }, ) confirmPrompt := utils.ResolvePlaceholderString( self.c.Tr.DeleteLocalAndRemoteTagPrompt, map[string]string{ "tagName": tag.Name, "upstream": upstream, }, ) self.c.Confirm(types.ConfirmOpts{ Title: confirmTitle, Prompt: confirmPrompt, HandleConfirm: func() error { return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag) if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tag.Name); err != nil { return err } self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) }) }, }) return nil }, }) return nil } func (self *TagsController) delete(tag *models.Tag) error { menuTitle := utils.ResolvePlaceholderString( self.c.Tr.DeleteTagTitle, map[string]string{ "tagName": tag.Name, }, ) menuItems := []*types.MenuItem{ { Label: self.c.Tr.DeleteLocalTag, Key: 'c', OnPress: func() error { return self.localDelete(tag) }, }, { Label: self.c.Tr.DeleteRemoteTag, Key: 'r', OpensMenu: true, OnPress: func() error { return self.remoteDelete(tag) }, }, { Label: self.c.Tr.DeleteLocalAndRemoteTag, Key: 'b', OpensMenu: true, OnPress: func() error { return self.localAndRemoteDelete(tag) }, }, } return self.c.Menu(types.CreateMenuOptions{ Title: menuTitle, Items: menuItems, }) } func (self *TagsController) push(tag *models.Tag) error { title := utils.ResolvePlaceholderString( self.c.Tr.PushTagTitle, map[string]string{ "tagName": tag.Name, }, ) self.c.Prompt(types.PromptOpts{ Title: title, InitialContent: "origin", FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(), HandleConfirm: func(response string) error { return self.c.WithInlineStatus(tag, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.PushTag) err := self.c.Git().Tag.Push(task, response, tag.Name) // Render again to remove the inline status: self.c.OnUIThread(func() error { self.c.Contexts().Tags.HandleRender() return nil }) return err }) }, }) return nil } func (self *TagsController) createResetMenu(tag *models.Tag) error { return self.c.Helpers().Refs.CreateGitResetMenu(tag.Name) } func (self *TagsController) create() error { // leaving commit hash blank so that we're just creating the tag for the current commit return self.c.Helpers().Tags.OpenCreateTagPrompt("", func() { self.context().SetSelection(0) }) } func (self *TagsController) context() *context.TagsContext { return self.c.Contexts().Tags } lazygit-0.50.0+ds1/pkg/gui/controllers/toggle_whitespace_action.go000066400000000000000000000017411500612110400252270ustar00rootroot00000000000000package controllers import ( "errors" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type ToggleWhitespaceAction struct { c *ControllerCommon } func (self *ToggleWhitespaceAction) Call() error { contextsThatDontSupportIgnoringWhitespace := []types.ContextKey{ context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY, context.PATCH_BUILDING_MAIN_CONTEXT_KEY, } if lo.Contains(contextsThatDontSupportIgnoringWhitespace, self.c.Context().Current().GetKey()) { // Ignoring whitespace is not supported in these views. Let the user // know that it's not going to work in case they try to turn it on. return errors.New(self.c.Tr.IgnoreWhitespaceNotSupportedHere) } self.c.GetAppState().IgnoreWhitespaceInDiffView = !self.c.GetAppState().IgnoreWhitespaceInDiffView self.c.SaveAppStateAndLogError() self.c.Context().CurrentSide().HandleFocus(types.OnFocusOpts{}) return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/undo_controller.go000066400000000000000000000212441500612110400234050ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) // Quick summary of how this all works: // when you want to undo or redo, we start from the top of the reflog and work // down until we've reached the last user-initiated reflog entry that hasn't already been undone // we then do the reverse of what that reflog describes. // When we do this, we create a new reflog entry, and tag it as either an undo or redo // Then, next time we want to undo, we'll use those entries to know which user-initiated // actions we can skip. E.g. if I do three things, A, B, and C, and hit undo twice, // the reflog will read UUCBA, and when I read the first two undos, I know to skip the following // two user actions, meaning we end up undoing reflog entry C. Redoing works in a similar way. type UndoController struct { baseController c *ControllerCommon } var _ types.IController = &UndoController{} func NewUndoController( c *ControllerCommon, ) *UndoController { return &UndoController{ baseController: baseController{}, c: c, } } type ReflogActionKind int const ( CHECKOUT ReflogActionKind = iota COMMIT REBASE CURRENT_REBASE ) type reflogAction struct { kind ReflogActionKind from string to string } func (self *UndoController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Undo), Handler: self.reflogUndo, Description: self.c.Tr.UndoReflog, Tooltip: self.c.Tr.UndoTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Redo), Handler: self.reflogRedo, Description: self.c.Tr.RedoReflog, Tooltip: self.c.Tr.RedoTooltip, }, } return bindings } func (self *UndoController) Context() types.Context { return nil } func (self *UndoController) reflogUndo() error { undoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit undo]"} undoingStatus := self.c.Tr.UndoingStatus if self.c.Git().Status.WorkingTreeState().Any() { return errors.New(self.c.Tr.CantUndoWhileRebasing) } return self.parseReflogForActions(func(counter int, action reflogAction) (bool, error) { if counter != 0 { return false, nil } switch action.kind { case COMMIT: self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Actions.Undo, Prompt: fmt.Sprintf(self.c.Tr.SoftResetPrompt, action.from), HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.Undo) return self.c.WithWaitingStatus(undoingStatus, func(gocui.Task) error { return self.c.Helpers().Refs.ResetToRef(action.from, "soft", undoEnvVars) }) }, }) return true, nil case REBASE: self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Actions.Undo, Prompt: fmt.Sprintf(self.c.Tr.HardResetAutostashPrompt, action.from), HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.Undo) return self.hardResetWithAutoStash(action.from, hardResetOptions{ EnvVars: undoEnvVars, WaitingStatus: undoingStatus, }) }, }) return true, nil case CHECKOUT: self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Actions.Undo, Prompt: fmt.Sprintf(self.c.Tr.CheckoutAutostashPrompt, action.from), HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.Undo) return self.c.Helpers().Refs.CheckoutRef(action.from, types.CheckoutRefOptions{ EnvVars: undoEnvVars, WaitingStatus: undoingStatus, }) }, }) return true, nil case CURRENT_REBASE: // do nothing } self.c.Log.Error("didn't match on the user action when trying to undo") return true, nil }) } func (self *UndoController) reflogRedo() error { redoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit redo]"} redoingStatus := self.c.Tr.RedoingStatus if self.c.Git().Status.WorkingTreeState().Any() { return errors.New(self.c.Tr.CantRedoWhileRebasing) } return self.parseReflogForActions(func(counter int, action reflogAction) (bool, error) { // if we're redoing and the counter is zero, we just return if counter == 0 { return true, nil } else if counter > 1 { return false, nil } switch action.kind { case COMMIT, REBASE: self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Actions.Redo, Prompt: fmt.Sprintf(self.c.Tr.HardResetAutostashPrompt, action.to), HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.Redo) return self.hardResetWithAutoStash(action.to, hardResetOptions{ EnvVars: redoEnvVars, WaitingStatus: redoingStatus, }) }, }) return true, nil case CHECKOUT: self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Actions.Redo, Prompt: fmt.Sprintf(self.c.Tr.CheckoutAutostashPrompt, action.to), HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.Redo) return self.c.Helpers().Refs.CheckoutRef(action.to, types.CheckoutRefOptions{ EnvVars: redoEnvVars, WaitingStatus: redoingStatus, }) }, }) return true, nil case CURRENT_REBASE: // do nothing } self.c.Log.Error("didn't match on the user action when trying to redo") return true, nil }) } // Here we're going through the reflog and maintaining a counter that represents how many // undos/redos/user actions we've seen. when we hit a user action we call the callback specifying // what the counter is up to and the nature of the action. // If we find ourselves mid-rebase, we just return because undo/redo mid rebase // requires knowledge of previous TODO file states, which you can't just get from the reflog. // Though we might support this later, hence the use of the CURRENT_REBASE action kind. func (self *UndoController) parseReflogForActions(onUserAction func(counter int, action reflogAction) (bool, error)) error { counter := 0 reflogCommits := self.c.Model().FilteredReflogCommits rebaseFinishCommitHash := "" var action *reflogAction for reflogCommitIdx, reflogCommit := range reflogCommits { action = nil prevCommitHash := "" if len(reflogCommits)-1 >= reflogCommitIdx+1 { prevCommitHash = reflogCommits[reflogCommitIdx+1].Hash() } if rebaseFinishCommitHash == "" { if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit undo\]`); ok { counter++ } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit redo\]`); ok { counter-- } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase (-i )?\(abort\)|^rebase (-i )?\(finish\)`); ok { rebaseFinishCommitHash = reflogCommit.Hash() } else if ok, match := utils.FindStringSubmatch(reflogCommit.Name, `^checkout: moving from ([\S]+) to ([\S]+)`); ok { action = &reflogAction{kind: CHECKOUT, from: match[1], to: match[2]} } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^commit|^reset: moving to|^pull`); ok { action = &reflogAction{kind: COMMIT, from: prevCommitHash, to: reflogCommit.Hash()} } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase (-i )?\(start\)`); ok { // if we're here then we must be currently inside an interactive rebase action = &reflogAction{kind: CURRENT_REBASE, from: prevCommitHash} } } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase (-i )?\(start\)`); ok { action = &reflogAction{kind: REBASE, from: prevCommitHash, to: rebaseFinishCommitHash} rebaseFinishCommitHash = "" } if action != nil { if action.kind != CURRENT_REBASE && action.from == action.to { // if we're going from one place to the same place we'll ignore the action. continue } ok, err := onUserAction(counter, *action) if ok { return err } counter-- } } return nil } type hardResetOptions struct { WaitingStatus string EnvVars []string } // only to be used in the undo flow for now (does an autostash) func (self *UndoController) hardResetWithAutoStash(commitHash string, options hardResetOptions) error { reset := func() error { return self.c.Helpers().Refs.ResetToRef(commitHash, "hard", options.EnvVars) } // if we have any modified tracked files we need to auto-stash dirtyWorkingTree := self.c.Helpers().WorkingTree.IsWorkingTreeDirty() if dirtyWorkingTree { return self.c.WithWaitingStatus(options.WaitingStatus, func(gocui.Task) error { if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + commitHash); err != nil { return err } if err := reset(); err != nil { return err } err := self.c.Git().Stash.Pop(0) if err != nil { return err } return self.c.Refresh(types.RefreshOptions{}) }) } return self.c.WithWaitingStatus(options.WaitingStatus, func(gocui.Task) error { return reset() }) } lazygit-0.50.0+ds1/pkg/gui/controllers/vertical_scroll_controller.go000066400000000000000000000037341500612110400256330ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) // given we have no fields here, arguably we shouldn't even need this factory // struct, but we're maintaining consistency with the other files. type VerticalScrollControllerFactory struct { c *ControllerCommon } func NewVerticalScrollControllerFactory(c *ControllerCommon) *VerticalScrollControllerFactory { return &VerticalScrollControllerFactory{ c: c, } } func (self *VerticalScrollControllerFactory) Create(context types.Context) types.IController { return &VerticalScrollController{ baseController: baseController{}, c: self.c, context: context, } } type VerticalScrollController struct { baseController c *ControllerCommon context types.Context } func (self *VerticalScrollController) Context() types.Context { return self.context } func (self *VerticalScrollController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{} } func (self *VerticalScrollController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.context.GetViewName(), Key: gocui.MouseWheelUp, Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollUp() }, }, { ViewName: self.context.GetViewName(), Key: gocui.MouseWheelDown, Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollDown() }, }, } } func (self *VerticalScrollController) HandleScrollUp() error { self.context.GetViewTrait().ScrollUp(self.c.UserConfig().Gui.ScrollHeight) return nil } func (self *VerticalScrollController) HandleScrollDown() error { scrollHeight := self.c.UserConfig().Gui.ScrollHeight self.context.GetViewTrait().ScrollDown(scrollHeight) if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { manager.ReadLines(scrollHeight) } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/view_selection_controller.go000066400000000000000000000067501500612110400254640ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type ViewSelectionControllerFactory struct { c *ControllerCommon } func NewViewSelectionControllerFactory(c *ControllerCommon) *ViewSelectionControllerFactory { return &ViewSelectionControllerFactory{ c: c, } } func (self *ViewSelectionControllerFactory) Create(context types.Context) types.IController { return &ViewSelectionController{ baseController: baseController{}, c: self.c, context: context, } } type ViewSelectionController struct { baseController c *ControllerCommon context types.Context } func (self *ViewSelectionController) Context() types.Context { return self.context } func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Handler: self.handlePrevLine}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.handleNextLine}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Handler: self.handleNextLine}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop, Alternative: ""}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom, Alternative: ""}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), Handler: self.handleGotoTop}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), Handler: self.handleGotoBottom}, } } func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{} } func (self *ViewSelectionController) handleLineChange(delta int) { if delta > 0 { if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { manager.ReadLines(delta) } } v := self.Context().GetView() if delta < 0 { v.ScrollUp(-delta) } else { v.ScrollDown(delta) } } func (self *ViewSelectionController) handlePrevLine() error { self.handleLineChange(-1) return nil } func (self *ViewSelectionController) handleNextLine() error { self.handleLineChange(1) return nil } func (self *ViewSelectionController) handlePrevPage() error { self.handleLineChange(-self.context.GetViewTrait().PageDelta()) return nil } func (self *ViewSelectionController) handleNextPage() error { self.handleLineChange(self.context.GetViewTrait().PageDelta()) return nil } func (self *ViewSelectionController) handleGotoTop() error { v := self.Context().GetView() self.handleLineChange(-v.ViewLinesHeight()) return nil } func (self *ViewSelectionController) handleGotoBottom() error { if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { manager.ReadToEnd(func() { self.c.OnUIThread(func() error { v := self.Context().GetView() self.handleLineChange(v.ViewLinesHeight()) return nil }) }) } return nil } lazygit-0.50.0+ds1/pkg/gui/controllers/workspace_reset_controller.go000066400000000000000000000154471500612110400256500ustar00rootroot00000000000000package controllers import ( "bytes" "errors" "fmt" "math" "math/rand" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" ) // this is in its own file given that the workspace controller file is already quite long func (self *FilesController) createResetMenu() error { red := style.FgRed nukeStr := "git reset --hard HEAD && git clean -fd" if len(self.c.Model().Submodules) > 0 { nukeStr = fmt.Sprintf("%s (%s)", nukeStr, self.c.Tr.AndResetSubmodules) } menuItems := []*types.MenuItem{ { LabelColumns: []string{ self.c.Tr.DiscardAllChangesToAllFiles, red.Sprint(nukeStr), }, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.NukeWorkingTree) if err := self.c.Git().WorkingTree.ResetAndClean(); err != nil { return err } if self.c.UserConfig().Gui.AnimateExplosion { self.animateExplosion() } return self.c.Refresh( types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, ) }, Key: 'x', Tooltip: self.c.Tr.NukeDescription, }, { LabelColumns: []string{ self.c.Tr.DiscardAnyUnstagedChanges, red.Sprint("git checkout -- ."), }, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.DiscardUnstagedFileChanges) if err := self.c.Git().WorkingTree.DiscardAnyUnstagedFileChanges(); err != nil { return err } return self.c.Refresh( types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, ) }, Key: 'u', }, { LabelColumns: []string{ self.c.Tr.DiscardUntrackedFiles, red.Sprint("git clean -fd"), }, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RemoveUntrackedFiles) if err := self.c.Git().WorkingTree.RemoveUntrackedFiles(); err != nil { return err } return self.c.Refresh( types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, ) }, Key: 'c', }, { LabelColumns: []string{ self.c.Tr.DiscardStagedChanges, red.Sprint("stash staged and drop stash"), }, Tooltip: self.c.Tr.DiscardStagedChangesDescription, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RemoveStagedFiles) if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() { return errors.New(self.c.Tr.NoTrackedStagedFilesStash) } if err := self.c.Git().Stash.SaveStagedChanges("[lazygit] tmp stash"); err != nil { return err } if err := self.c.Git().Stash.DropNewest(); err != nil { return err } return self.c.Refresh( types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, ) }, Key: 'S', }, { LabelColumns: []string{ self.c.Tr.SoftReset, red.Sprint("git reset --soft HEAD"), }, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.SoftReset) if err := self.c.Git().WorkingTree.ResetSoft("HEAD"); err != nil { return err } return self.c.Refresh( types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, ) }, Key: 's', }, { LabelColumns: []string{ "mixed reset", red.Sprint("git reset --mixed HEAD"), }, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.MixedReset) if err := self.c.Git().WorkingTree.ResetMixed("HEAD"); err != nil { return err } return self.c.Refresh( types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, ) }, Key: 'm', }, { LabelColumns: []string{ self.c.Tr.HardReset, red.Sprint("git reset --hard HEAD"), }, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.HardReset) if err := self.c.Git().WorkingTree.ResetHard("HEAD"); err != nil { return err } return self.c.Refresh( types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, ) }, Key: 'h', }, } return self.c.Menu(types.CreateMenuOptions{Title: "", Items: menuItems}) } func (self *FilesController) animateExplosion() { self.Explode(self.c.Views().Files, func() { self.c.PostRefreshUpdate(self.c.Contexts().Files) }) } // Animates an explosion within the view by drawing a bunch of flamey characters func (self *FilesController) Explode(v *gocui.View, onDone func()) { width := v.InnerWidth() height := v.InnerHeight() styles := []style.TextStyle{ style.FgLightWhite.SetBold(), style.FgYellow.SetBold(), style.FgRed.SetBold(), style.FgBlue.SetBold(), style.FgBlack.SetBold(), } self.c.OnWorker(func(_ gocui.Task) error { max := 25 for i := 0; i < max; i++ { image := getExplodeImage(width, height, i, max) style := styles[(i*len(styles)/max)%len(styles)] coloredImage := style.Sprint(image) self.c.OnUIThread(func() error { v.SetOrigin(0, 0) v.SetContent(coloredImage) return nil }) time.Sleep(time.Millisecond * 20) } self.c.OnUIThread(func() error { v.Clear() onDone() return nil }) return nil }) } // Render an explosion in the given bounds. func getExplodeImage(width int, height int, frame int, max int) string { // Predefine the explosion symbols explosionChars := []rune{'*', '.', '@', '#', '&', '+', '%'} // Initialize a buffer to build our string var buf bytes.Buffer // Initialize RNG seed random := rand.New(rand.NewSource(time.Now().UnixNano())) // calculate the center of explosion centerX, centerY := width/2, height/2 // calculate the max radius (hypotenuse of the view) maxRadius := math.Hypot(float64(centerX), float64(centerY)) // calculate frame as a proportion of max, apply square root to create the non-linear effect progress := math.Sqrt(float64(frame) / float64(max)) // calculate radius of explosion according to frame and max radius := progress * maxRadius * 2 // introduce a new radius for the inner boundary of the explosion (the shockwave effect) var innerRadius float64 if progress > 0.5 { innerRadius = (progress - 0.5) * 2 * maxRadius } for y := 0; y < height; y++ { for x := 0; x < width; x++ { // calculate distance from center, scale x by 2 to compensate for character aspect ratio distance := math.Hypot(float64(x-centerX), float64(y-centerY)*2) // if distance is less than radius and greater than innerRadius, draw explosion char if distance <= radius && distance >= innerRadius { // Make placement random and less likely as explosion progresses if random.Float64() > progress { // Pick a random explosion char char := explosionChars[random.Intn(len(explosionChars))] buf.WriteRune(char) } else { buf.WriteRune(' ') } } else { // If not explosion, then it's empty space buf.WriteRune(' ') } } // End of line if y < height-1 { buf.WriteRune('\n') } } return buf.String() } lazygit-0.50.0+ds1/pkg/gui/controllers/worktree_options_controller.go000066400000000000000000000024411500612110400260530ustar00rootroot00000000000000package controllers import ( "github.com/jesseduffield/lazygit/pkg/gui/types" ) // This controller is for all contexts that have items you can create a worktree from var _ types.IController = &WorktreeOptionsController{} type CanViewWorktreeOptions interface { types.IListContext } type WorktreeOptionsController struct { baseController *ListControllerTrait[string] c *ControllerCommon context CanViewWorktreeOptions } func NewWorktreeOptionsController(c *ControllerCommon, context CanViewWorktreeOptions) *WorktreeOptionsController { return &WorktreeOptionsController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, context, context.GetSelectedItemId, context.GetSelectedItemIds, ), c: c, context: context, } } func (self *WorktreeOptionsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Worktrees.ViewWorktreeOptions), Handler: self.withItem(self.viewWorktreeOptions), Description: self.c.Tr.ViewWorktreeOptions, OpensMenu: true, }, } return bindings } func (self *WorktreeOptionsController) viewWorktreeOptions(ref string) error { return self.c.Helpers().Worktree.ViewWorktreeOptions(self.context, ref) } lazygit-0.50.0+ds1/pkg/gui/controllers/worktrees_controller.go000066400000000000000000000101251500612110400244610ustar00rootroot00000000000000package controllers import ( "errors" "fmt" "strings" "text/tabwriter" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type WorktreesController struct { baseController *ListControllerTrait[*models.Worktree] c *ControllerCommon } var _ types.IController = &WorktreesController{} func NewWorktreesController( c *ControllerCommon, ) *WorktreesController { return &WorktreesController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, c.Contexts().Worktrees, c.Contexts().Worktrees.GetSelected, c.Contexts().Worktrees.GetSelectedItems, ), c: c, } } func (self *WorktreesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.New), Handler: self.add, Description: self.c.Tr.NewWorktree, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Switch, Tooltip: self.c.Tr.SwitchToWorktreeTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.Confirm), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), }, { Key: opts.GetKey(opts.Config.Universal.OpenFile), Handler: self.withItem(self.open), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenInEditor, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItem(self.remove), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Remove, Tooltip: self.c.Tr.RemoveWorktreeTooltip, DisplayOnScreen: true, }, } return bindings } func (self *WorktreesController) GetOnRenderToMain() func() { return func() { var task types.UpdateTask worktree := self.context().GetSelected() if worktree == nil { task = types.NewRenderStringTask(self.c.Tr.NoWorktreesThisRepo) } else { main := "" if worktree.IsMain { main = style.FgDefault.Sprintf(" %s", self.c.Tr.MainWorktree) } missing := "" if worktree.IsPathMissing { missing = style.FgRed.Sprintf(" %s", self.c.Tr.MissingWorktree) } var builder strings.Builder w := tabwriter.NewWriter(&builder, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "%s:\t%s%s\n", self.c.Tr.Name, style.FgGreen.Sprint(worktree.Name), main) _, _ = fmt.Fprintf(w, "%s:\t%s\n", self.c.Tr.Branch, style.FgYellow.Sprint(worktree.Branch)) _, _ = fmt.Fprintf(w, "%s:\t%s%s\n", self.c.Tr.Path, style.FgCyan.Sprint(worktree.Path), missing) _ = w.Flush() task = types.NewRenderStringTask(builder.String()) } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: self.c.Tr.WorktreeTitle, Task: task, }, }) } } func (self *WorktreesController) add() error { return self.c.Helpers().Worktree.NewWorktree() } func (self *WorktreesController) remove(worktree *models.Worktree) error { if worktree.IsMain { return errors.New(self.c.Tr.CantDeleteMainWorktree) } if worktree.IsCurrent { return errors.New(self.c.Tr.CantDeleteCurrentWorktree) } return self.c.Helpers().Worktree.Remove(worktree, false) } func (self *WorktreesController) GetOnClick() func() error { return self.withItemGraceful(self.enter) } func (self *WorktreesController) enter(worktree *models.Worktree) error { return self.c.Helpers().Worktree.Switch(worktree, context.WORKTREES_CONTEXT_KEY) } func (self *WorktreesController) open(worktree *models.Worktree) error { return self.c.Helpers().Files.OpenDirInEditor(worktree.Path) } func (self *WorktreesController) context() *context.WorktreesContext { return self.c.Contexts().Worktrees } lazygit-0.50.0+ds1/pkg/gui/dummies.go000066400000000000000000000014371500612110400172740ustar00rootroot00000000000000package gui import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/updates" "github.com/jesseduffield/lazygit/pkg/utils" ) func NewDummyUpdater() *updates.Updater { newAppConfig := config.NewDummyAppConfig() dummyUpdater, _ := updates.NewUpdater(utils.NewDummyCommon(), newAppConfig, oscommands.NewDummyOSCommand()) return dummyUpdater } // NewDummyGui creates a new dummy GUI for testing func NewDummyGui() *Gui { newAppConfig := config.NewDummyAppConfig() dummyGui, _ := NewGui(utils.NewDummyCommon(), newAppConfig, &git_commands.GitVersion{Major: 2, Minor: 0, Patch: 0}, NewDummyUpdater(), false, "", nil) return dummyGui } lazygit-0.50.0+ds1/pkg/gui/editors.go000066400000000000000000000057501500612110400173040ustar00rootroot00000000000000package gui import ( "unicode" "github.com/jesseduffield/gocui" ) func (gui *Gui) handleEditorKeypress(textArea *gocui.TextArea, key gocui.Key, ch rune, mod gocui.Modifier, allowMultiline bool) bool { switch { case key == gocui.KeyBackspace || key == gocui.KeyBackspace2: textArea.BackSpaceChar() case key == gocui.KeyCtrlD || key == gocui.KeyDelete: textArea.DeleteChar() case key == gocui.KeyArrowDown: textArea.MoveCursorDown() case key == gocui.KeyArrowUp: textArea.MoveCursorUp() case (key == gocui.KeyArrowLeft || ch == 'b') && (mod&gocui.ModAlt) != 0: textArea.MoveLeftWord() case key == gocui.KeyArrowLeft || key == gocui.KeyCtrlB: textArea.MoveCursorLeft() case (key == gocui.KeyArrowRight || ch == 'f') && (mod&gocui.ModAlt) != 0: textArea.MoveRightWord() case key == gocui.KeyArrowRight || key == gocui.KeyCtrlF: textArea.MoveCursorRight() case key == gocui.KeyEnter: if allowMultiline { textArea.TypeRune('\n') } else { return false } case key == gocui.KeySpace: textArea.TypeRune(' ') case key == gocui.KeyInsert: textArea.ToggleOverwrite() case key == gocui.KeyCtrlU: textArea.DeleteToStartOfLine() case key == gocui.KeyCtrlK: textArea.DeleteToEndOfLine() case key == gocui.KeyCtrlA || key == gocui.KeyHome: textArea.GoToStartOfLine() case key == gocui.KeyCtrlE || key == gocui.KeyEnd: textArea.GoToEndOfLine() case key == gocui.KeyCtrlW: textArea.BackSpaceWord() case key == gocui.KeyCtrlY: textArea.Yank() case unicode.IsPrint(ch): textArea.TypeRune(ch) default: return false } return true } // we've just copy+pasted the editor from gocui to here so that we can also re- // render the commit message length on each keypress func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { matched := gui.handleEditorKeypress(v.TextArea, key, ch, mod, false) v.RenderTextArea() gui.c.Contexts().CommitMessage.RenderSubtitle() return matched } func (gui *Gui) commitDescriptionEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { matched := gui.handleEditorKeypress(v.TextArea, key, ch, mod, true) v.RenderTextArea() return matched } func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { matched := gui.handleEditorKeypress(v.TextArea, key, ch, mod, false) v.RenderTextArea() suggestionsContext := gui.State.Contexts.Suggestions if suggestionsContext.State.FindSuggestions != nil { input := v.TextArea.GetContent() suggestionsContext.State.AsyncHandler.Do(func() func() { suggestions := suggestionsContext.State.FindSuggestions(input) return func() { suggestionsContext.SetSuggestions(suggestions) } }) } return matched } func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { matched := gui.handleEditorKeypress(v.TextArea, key, ch, mod, false) v.RenderTextArea() searchString := v.TextArea.GetContent() gui.helpers.Search.OnPromptContentChanged(searchString) return matched } lazygit-0.50.0+ds1/pkg/gui/extras_panel.go000066400000000000000000000060751500612110400203210ustar00rootroot00000000000000package gui import ( "io" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" ) func (gui *Gui) handleCreateExtrasMenuPanel() error { return gui.c.Menu(types.CreateMenuOptions{ Title: gui.c.Tr.CommandLog, Items: []*types.MenuItem{ { Label: gui.c.Tr.ToggleShowCommandLog, OnPress: func() error { currentContext := gui.c.Context().CurrentStatic() if gui.c.State().GetShowExtrasWindow() && currentContext.GetKey() == context.COMMAND_LOG_CONTEXT_KEY { gui.c.Context().Pop() } show := !gui.c.State().GetShowExtrasWindow() gui.c.State().SetShowExtrasWindow(show) gui.c.GetAppState().HideCommandLog = !show gui.c.SaveAppStateAndLogError() return nil }, }, { Label: gui.c.Tr.FocusCommandLog, OnPress: gui.handleFocusCommandLog, }, }, }) } func (gui *Gui) handleFocusCommandLog() error { gui.c.State().SetShowExtrasWindow(true) // TODO: is this necessary? Can't I just call 'return from context'? gui.State.Contexts.CommandLog.SetParentContext(gui.c.Context().CurrentSide()) gui.c.Context().Push(gui.State.Contexts.CommandLog, types.OnFocusOpts{}) return nil } func (gui *Gui) scrollUpExtra() error { gui.Views.Extras.Autoscroll = false gui.scrollUpView(gui.Views.Extras) return nil } func (gui *Gui) scrollDownExtra() error { gui.Views.Extras.Autoscroll = false gui.scrollDownView(gui.Views.Extras) return nil } func (gui *Gui) pageUpExtrasPanel() error { gui.Views.Extras.Autoscroll = false gui.Views.Extras.ScrollUp(gui.Contexts().CommandLog.GetViewTrait().PageDelta()) return nil } func (gui *Gui) pageDownExtrasPanel() error { gui.Views.Extras.Autoscroll = false gui.Views.Extras.ScrollDown(gui.Contexts().CommandLog.GetViewTrait().PageDelta()) return nil } func (gui *Gui) goToExtrasPanelTop() error { gui.Views.Extras.Autoscroll = false gui.Views.Extras.ScrollUp(gui.Views.Extras.ViewLinesHeight()) return nil } func (gui *Gui) goToExtrasPanelBottom() error { gui.Views.Extras.Autoscroll = true gui.Views.Extras.ScrollDown(gui.Views.Extras.ViewLinesHeight()) return nil } func (gui *Gui) getCmdWriter() io.Writer { return &prefixWriter{writer: gui.Views.Extras, prefix: style.FgMagenta.Sprintf("\n\n%s\n", gui.c.Tr.GitOutput)} } // Ensures that the first write is preceded by writing a prefix. // This allows us to say 'Git output:' before writing the actual git output. // We could just write directly to the view in this package before running the command but we already have code in the commands package that writes to the same view beforehand (with the command it's about to run) so things would be out of order. type prefixWriter struct { prefix string prefixWritten bool writer io.Writer } func (self *prefixWriter) Write(p []byte) (int, error) { if !self.prefixWritten { self.prefixWritten = true // assuming we can write this prefix in one go n, err := self.writer.Write([]byte(self.prefix)) if err != nil { return n, err } } return self.writer.Write(p) } lazygit-0.50.0+ds1/pkg/gui/filetree/000077500000000000000000000000001500612110400170745ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/filetree/README.md000066400000000000000000000015421500612110400203550ustar00rootroot00000000000000## FileTree Package This package handles the representation of file trees. There are two ways to render files: one is to render them flat, so something like this: ``` dir1/file1 dir1/file2 file3 ``` And the other is to render them as a tree ``` dir1/ file1 file2 file3 ``` Internally we represent each of the above as a tree, but with the flat approach there's just a single root node and every path is a direct child of that root. Viewing in 'tree' mode (as opposed to 'flat' mode) allows for collapsing and expanding directories, and lets you perform actions on directories e.g. staging a whole directory. But it takes up more vertical space and sometimes you just want to have a flat view where you can go flick through your files one by one to see the diff. This package is not concerned about rendering the tree: only representing its internal state. lazygit-0.50.0+ds1/pkg/gui/filetree/build_tree.go000066400000000000000000000070751500612110400215520ustar00rootroot00000000000000package filetree import ( "sort" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" ) func BuildTreeFromFiles(files []*models.File) *Node[models.File] { root := &Node[models.File]{} childrenMapsByNode := make(map[*Node[models.File]]map[string]*Node[models.File]) var curr *Node[models.File] for _, file := range files { splitPath := split("./" + file.Path) curr = root outer: for i := range splitPath { var setFile *models.File isFile := i == len(splitPath)-1 if isFile { setFile = file } path := join(splitPath[:i+1]) var currNodeChildrenMap map[string]*Node[models.File] var isCurrNodeMapped bool if currNodeChildrenMap, isCurrNodeMapped = childrenMapsByNode[curr]; !isCurrNodeMapped { currNodeChildrenMap = make(map[string]*Node[models.File]) childrenMapsByNode[curr] = currNodeChildrenMap } child, doesCurrNodeHaveChildAlready := currNodeChildrenMap[path] if doesCurrNodeHaveChildAlready { curr = child continue outer } if i == 0 && len(files) == 1 && len(splitPath) == 2 { // skip the root item when there's only one file at top level; we don't need it in that case continue outer } newChild := &Node[models.File]{ path: path, File: setFile, } curr.Children = append(curr.Children, newChild) currNodeChildrenMap[path] = newChild curr = newChild } } root.Sort() root.Compress() return root } func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] { rootAux := BuildTreeFromCommitFiles(files) sortedFiles := rootAux.GetLeaves() return &Node[models.CommitFile]{Children: sortedFiles} } func BuildTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] { root := &Node[models.CommitFile]{} var curr *Node[models.CommitFile] for _, file := range files { splitPath := split("./" + file.Path) curr = root outer: for i := range splitPath { var setFile *models.CommitFile isFile := i == len(splitPath)-1 if isFile { setFile = file } path := join(splitPath[:i+1]) for _, existingChild := range curr.Children { if existingChild.path == path { curr = existingChild continue outer } } if i == 0 && len(files) == 1 && len(splitPath) == 2 { // skip the root item when there's only one file at top level; we don't need it in that case continue outer } newChild := &Node[models.CommitFile]{ path: path, File: setFile, } curr.Children = append(curr.Children, newChild) curr = newChild } } root.Sort() root.Compress() return root } func BuildFlatTreeFromFiles(files []*models.File) *Node[models.File] { rootAux := BuildTreeFromFiles(files) sortedFiles := rootAux.GetLeaves() // from top down we have merge conflict files, then tracked file, then untracked // files. This is the one way in which sorting differs between flat mode and // tree mode sort.SliceStable(sortedFiles, func(i, j int) bool { iFile := sortedFiles[i].File jFile := sortedFiles[j].File // never going to happen but just to be safe if iFile == nil || jFile == nil { return false } if iFile.HasMergeConflicts && !jFile.HasMergeConflicts { return true } if jFile.HasMergeConflicts && !iFile.HasMergeConflicts { return false } if iFile.Tracked && !jFile.Tracked { return true } if jFile.Tracked && !iFile.Tracked { return false } return false }) return &Node[models.File]{Children: sortedFiles} } func split(str string) []string { return strings.Split(str, "/") } func join(strs []string) string { return strings.Join(strs, "/") } lazygit-0.50.0+ds1/pkg/gui/filetree/build_tree_test.go000066400000000000000000000245761500612110400226160ustar00rootroot00000000000000package filetree import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/stretchr/testify/assert" ) func TestBuildTreeFromFiles(t *testing.T) { scenarios := []struct { name string files []*models.File expected *Node[models.File] }{ { name: "no files", files: []*models.File{}, expected: &Node[models.File]{ path: "", Children: nil, }, }, { name: "files in same directory", files: []*models.File{ { Path: "dir1/a", }, { Path: "dir1/b", }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { path: "./dir1", CompressionLevel: 1, Children: []*Node[models.File]{ { File: &models.File{Path: "dir1/a"}, path: "./dir1/a", }, { File: &models.File{Path: "dir1/b"}, path: "./dir1/b", }, }, }, }, }, }, { name: "paths that can be compressed", files: []*models.File{ { Path: "dir1/dir3/a", }, { Path: "dir2/dir4/b", }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { path: ".", Children: []*Node[models.File]{ { path: "./dir1/dir3", Children: []*Node[models.File]{ { File: &models.File{Path: "dir1/dir3/a"}, path: "./dir1/dir3/a", }, }, CompressionLevel: 1, }, { path: "./dir2/dir4", Children: []*Node[models.File]{ { File: &models.File{Path: "dir2/dir4/b"}, path: "./dir2/dir4/b", }, }, CompressionLevel: 1, }, }, }, }, }, }, { name: "paths that can be sorted", files: []*models.File{ { Path: "b", }, { Path: "a", }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { path: ".", Children: []*Node[models.File]{ { File: &models.File{Path: "a"}, path: "./a", }, { File: &models.File{Path: "b"}, path: "./b", }, }, }, }, }, }, { name: "paths that can be sorted including a merge conflict file", files: []*models.File{ { Path: "b", }, { Path: "z", HasMergeConflicts: true, }, { Path: "a", }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { path: ".", // it is a little strange that we're not bubbling up our merge conflict // here but we are technically still in tree mode and that's the rule Children: []*Node[models.File]{ { File: &models.File{Path: "a"}, path: "./a", }, { File: &models.File{Path: "b"}, path: "./b", }, { File: &models.File{Path: "z", HasMergeConflicts: true}, path: "./z", }, }, }, }, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result := BuildTreeFromFiles(s.files) assert.EqualValues(t, s.expected, result) }) } } func TestBuildFlatTreeFromFiles(t *testing.T) { scenarios := []struct { name string files []*models.File expected *Node[models.File] }{ { name: "no files", files: []*models.File{}, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{}, }, }, { name: "files in same directory", files: []*models.File{ { Path: "dir1/a", }, { Path: "dir1/b", }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { File: &models.File{Path: "dir1/a"}, path: "./dir1/a", CompressionLevel: 0, }, { File: &models.File{Path: "dir1/b"}, path: "./dir1/b", CompressionLevel: 0, }, }, }, }, { name: "paths that can be compressed", files: []*models.File{ { Path: "dir1/a", }, { Path: "dir2/b", }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { File: &models.File{Path: "dir1/a"}, path: "./dir1/a", CompressionLevel: 0, }, { File: &models.File{Path: "dir2/b"}, path: "./dir2/b", CompressionLevel: 0, }, }, }, }, { name: "paths that can be sorted", files: []*models.File{ { Path: "b", }, { Path: "a", }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { File: &models.File{Path: "a"}, path: "./a", }, { File: &models.File{Path: "b"}, path: "./b", }, }, }, }, { name: "tracked, untracked, and conflicted files", files: []*models.File{ { Path: "a2", Tracked: false, }, { Path: "a1", Tracked: false, }, { Path: "c2", HasMergeConflicts: true, }, { Path: "c1", HasMergeConflicts: true, }, { Path: "b2", Tracked: true, }, { Path: "b1", Tracked: true, }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { File: &models.File{Path: "c1", HasMergeConflicts: true}, path: "./c1", }, { File: &models.File{Path: "c2", HasMergeConflicts: true}, path: "./c2", }, { File: &models.File{Path: "b1", Tracked: true}, path: "./b1", }, { File: &models.File{Path: "b2", Tracked: true}, path: "./b2", }, { File: &models.File{Path: "a1", Tracked: false}, path: "./a1", }, { File: &models.File{Path: "a2", Tracked: false}, path: "./a2", }, }, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result := BuildFlatTreeFromFiles(s.files) assert.EqualValues(t, s.expected, result) }) } } func TestBuildTreeFromCommitFiles(t *testing.T) { scenarios := []struct { name string files []*models.CommitFile expected *Node[models.CommitFile] }{ { name: "no files", files: []*models.CommitFile{}, expected: &Node[models.CommitFile]{ path: "", Children: nil, }, }, { name: "files in same directory", files: []*models.CommitFile{ { Path: "dir1/a", }, { Path: "dir1/b", }, }, expected: &Node[models.CommitFile]{ path: "", Children: []*Node[models.CommitFile]{ { path: "./dir1", CompressionLevel: 1, Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Path: "dir1/a"}, path: "./dir1/a", }, { File: &models.CommitFile{Path: "dir1/b"}, path: "./dir1/b", }, }, }, }, }, }, { name: "paths that can be compressed", files: []*models.CommitFile{ { Path: "dir1/dir3/a", }, { Path: "dir2/dir4/b", }, }, expected: &Node[models.CommitFile]{ path: "", Children: []*Node[models.CommitFile]{ { path: ".", Children: []*Node[models.CommitFile]{ { path: "./dir1/dir3", Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Path: "dir1/dir3/a"}, path: "./dir1/dir3/a", }, }, CompressionLevel: 1, }, { path: "./dir2/dir4", Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Path: "dir2/dir4/b"}, path: "./dir2/dir4/b", }, }, CompressionLevel: 1, }, }, }, }, }, }, { name: "paths that can be sorted", files: []*models.CommitFile{ { Path: "b", }, { Path: "a", }, }, expected: &Node[models.CommitFile]{ path: "", Children: []*Node[models.CommitFile]{ { path: ".", Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Path: "a"}, path: "./a", }, { File: &models.CommitFile{Path: "b"}, path: "./b", }, }, }, }, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result := BuildTreeFromCommitFiles(s.files) assert.EqualValues(t, s.expected, result) }) } } func TestBuildFlatTreeFromCommitFiles(t *testing.T) { scenarios := []struct { name string files []*models.CommitFile expected *Node[models.CommitFile] }{ { name: "no files", files: []*models.CommitFile{}, expected: &Node[models.CommitFile]{ path: "", Children: []*Node[models.CommitFile]{}, }, }, { name: "files in same directory", files: []*models.CommitFile{ { Path: "dir1/a", }, { Path: "dir1/b", }, }, expected: &Node[models.CommitFile]{ path: "", Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Path: "dir1/a"}, path: "./dir1/a", CompressionLevel: 0, }, { File: &models.CommitFile{Path: "dir1/b"}, path: "./dir1/b", CompressionLevel: 0, }, }, }, }, { name: "paths that can be compressed", files: []*models.CommitFile{ { Path: "dir1/a", }, { Path: "dir2/b", }, }, expected: &Node[models.CommitFile]{ path: "", Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Path: "dir1/a"}, path: "./dir1/a", CompressionLevel: 0, }, { File: &models.CommitFile{Path: "dir2/b"}, path: "./dir2/b", CompressionLevel: 0, }, }, }, }, { name: "paths that can be sorted", files: []*models.CommitFile{ { Path: "b", }, { Path: "a", }, }, expected: &Node[models.CommitFile]{ path: "", Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Path: "a"}, path: "./a", }, { File: &models.CommitFile{Path: "b"}, path: "./b", }, }, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result := BuildFlatTreeFromCommitFiles(s.files) assert.EqualValues(t, s.expected, result) }) } } lazygit-0.50.0+ds1/pkg/gui/filetree/collapsed_paths.go000066400000000000000000000017501500612110400225730ustar00rootroot00000000000000package filetree import "github.com/jesseduffield/generics/set" type CollapsedPaths struct { collapsedPaths *set.Set[string] } func NewCollapsedPaths() *CollapsedPaths { return &CollapsedPaths{ collapsedPaths: set.New[string](), } } func (self *CollapsedPaths) ExpandToPath(path string) { // need every directory along the way splitPath := split(path) for i := range splitPath { dir := join(splitPath[0 : i+1]) self.collapsedPaths.Remove(dir) } } func (self *CollapsedPaths) IsCollapsed(path string) bool { return self.collapsedPaths.Includes(path) } func (self *CollapsedPaths) Collapse(path string) { self.collapsedPaths.Add(path) } func (self *CollapsedPaths) ToggleCollapsed(path string) { if self.collapsedPaths.Includes(path) { self.collapsedPaths.Remove(path) } else { self.collapsedPaths.Add(path) } } func (self *CollapsedPaths) ExpandAll() { // Could be cleaner if Set had a Clear() method... self.collapsedPaths.RemoveSlice(self.collapsedPaths.ToSlice()) } lazygit-0.50.0+ds1/pkg/gui/filetree/commit_file_node.go000066400000000000000000000010711500612110400227160ustar00rootroot00000000000000package filetree import "github.com/jesseduffield/lazygit/pkg/commands/models" // CommitFileNode wraps a node and provides some commit-file-specific methods for it. type CommitFileNode struct { *Node[models.CommitFile] } func NewCommitFileNode(node *Node[models.CommitFile]) *CommitFileNode { if node == nil { return nil } return &CommitFileNode{Node: node} } // returns the underlying node, without any commit-file-specific methods attached func (self *CommitFileNode) Raw() *Node[models.CommitFile] { if self == nil { return nil } return self.Node } lazygit-0.50.0+ds1/pkg/gui/filetree/commit_file_tree.go000066400000000000000000000064311500612110400227350ustar00rootroot00000000000000package filetree import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" "github.com/sirupsen/logrus" ) type ICommitFileTree interface { ITree[models.CommitFile] Get(index int) *CommitFileNode GetFile(path string) *models.CommitFile GetAllItems() []*CommitFileNode GetAllFiles() []*models.CommitFile GetRoot() *CommitFileNode } type CommitFileTree struct { getFiles func() []*models.CommitFile tree *Node[models.CommitFile] showTree bool log *logrus.Entry collapsedPaths *CollapsedPaths } func (self *CommitFileTree) CollapseAll() { dirPaths := lo.FilterMap(self.GetAllItems(), func(file *CommitFileNode, index int) (string, bool) { return file.path, !file.IsFile() }) for _, path := range dirPaths { self.collapsedPaths.Collapse(path) } } func (self *CommitFileTree) ExpandAll() { self.collapsedPaths.ExpandAll() } var _ ICommitFileTree = &CommitFileTree{} func NewCommitFileTree(getFiles func() []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTree { return &CommitFileTree{ getFiles: getFiles, log: log, showTree: showTree, collapsedPaths: NewCollapsedPaths(), } } func (self *CommitFileTree) ExpandToPath(path string) { self.collapsedPaths.ExpandToPath(path) } func (self *CommitFileTree) ToggleShowTree() { self.showTree = !self.showTree self.SetTree() } func (self *CommitFileTree) Get(index int) *CommitFileNode { // need to traverse the three depth first until we get to the index. return NewCommitFileNode(self.tree.GetNodeAtIndex(index+1, self.collapsedPaths)) // ignoring root } func (self *CommitFileTree) GetIndexForPath(path string) (int, bool) { index, found := self.tree.GetIndexForPath(path, self.collapsedPaths) return index - 1, found } func (self *CommitFileTree) GetAllItems() []*CommitFileNode { if self.tree == nil { return nil } // ignoring root return lo.Map(self.tree.Flatten(self.collapsedPaths)[1:], func(node *Node[models.CommitFile], _ int) *CommitFileNode { return NewCommitFileNode(node) }) } func (self *CommitFileTree) Len() int { return self.tree.Size(self.collapsedPaths) - 1 // ignoring root } func (self *CommitFileTree) GetItem(index int) types.HasUrn { // Unimplemented because we don't yet need to show inlines statuses in commit file views return nil } func (self *CommitFileTree) GetAllFiles() []*models.CommitFile { return self.getFiles() } func (self *CommitFileTree) SetTree() { if self.showTree { self.tree = BuildTreeFromCommitFiles(self.getFiles()) } else { self.tree = BuildFlatTreeFromCommitFiles(self.getFiles()) } } func (self *CommitFileTree) IsCollapsed(path string) bool { return self.collapsedPaths.IsCollapsed(path) } func (self *CommitFileTree) ToggleCollapsed(path string) { self.collapsedPaths.ToggleCollapsed(path) } func (self *CommitFileTree) GetRoot() *CommitFileNode { return NewCommitFileNode(self.tree) } func (self *CommitFileTree) CollapsedPaths() *CollapsedPaths { return self.collapsedPaths } func (self *CommitFileTree) GetFile(path string) *models.CommitFile { for _, file := range self.getFiles() { if file.Path == path { return file } } return nil } func (self *CommitFileTree) InTreeMode() bool { return self.showTree } lazygit-0.50.0+ds1/pkg/gui/filetree/commit_file_tree_view_model.go000066400000000000000000000106501500612110400251450ustar00rootroot00000000000000package filetree import ( "strings" "sync" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" "github.com/sirupsen/logrus" ) type ICommitFileTreeViewModel interface { ICommitFileTree types.IListCursor GetRef() types.Ref SetRef(types.Ref) GetRefRange() *types.RefRange // can be nil, in which case GetRef should be used SetRefRange(*types.RefRange) // should be set to nil when selection is not a range GetCanRebase() bool SetCanRebase(bool) } type CommitFileTreeViewModel struct { sync.RWMutex types.IListCursor ICommitFileTree // this is e.g. the commit for which we're viewing the files, if there is no // range selection, or if the range selection can't be used for some reason ref types.Ref // this is a commit range for which we're viewing the files. Can be nil, in // which case ref is used. refRange *types.RefRange // we set this to true when you're viewing the files within the checked-out branch's commits. // If you're viewing the files of some random other branch we can't do any rebase stuff. canRebase bool } var _ ICommitFileTreeViewModel = &CommitFileTreeViewModel{} func NewCommitFileTreeViewModel(getFiles func() []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTreeViewModel { fileTree := NewCommitFileTree(getFiles, log, showTree) listCursor := traits.NewListCursor(fileTree.Len) return &CommitFileTreeViewModel{ ICommitFileTree: fileTree, IListCursor: listCursor, ref: nil, refRange: nil, canRebase: false, } } func (self *CommitFileTreeViewModel) GetRef() types.Ref { return self.ref } func (self *CommitFileTreeViewModel) SetRef(ref types.Ref) { self.ref = ref } func (self *CommitFileTreeViewModel) GetRefRange() *types.RefRange { return self.refRange } func (self *CommitFileTreeViewModel) SetRefRange(refsForRange *types.RefRange) { self.refRange = refsForRange } func (self *CommitFileTreeViewModel) GetCanRebase() bool { return self.canRebase } func (self *CommitFileTreeViewModel) SetCanRebase(canRebase bool) { self.canRebase = canRebase } func (self *CommitFileTreeViewModel) GetSelected() *CommitFileNode { if self.Len() == 0 { return nil } return self.Get(self.GetSelectedLineIdx()) } func (self *CommitFileTreeViewModel) GetSelectedItemId() string { item := self.GetSelected() if item == nil { return "" } return item.ID() } func (self *CommitFileTreeViewModel) GetSelectedItems() ([]*CommitFileNode, int, int) { if self.Len() == 0 { return nil, 0, 0 } startIdx, endIdx := self.GetSelectionRange() nodes := []*CommitFileNode{} for i := startIdx; i <= endIdx; i++ { nodes = append(nodes, self.Get(i)) } return nodes, startIdx, endIdx } func (self *CommitFileTreeViewModel) GetSelectedItemIds() ([]string, int, int) { selectedItems, startIdx, endIdx := self.GetSelectedItems() ids := lo.Map(selectedItems, func(item *CommitFileNode, _ int) string { return item.ID() }) return ids, startIdx, endIdx } func (self *CommitFileTreeViewModel) GetSelectedFile() *models.CommitFile { node := self.GetSelected() if node == nil { return nil } return node.File } func (self *CommitFileTreeViewModel) GetSelectedPath() string { node := self.GetSelected() if node == nil { return "" } return node.GetPath() } // duplicated from file_tree_view_model.go. Generics will help here func (self *CommitFileTreeViewModel) ToggleShowTree() { selectedNode := self.GetSelected() self.ICommitFileTree.ToggleShowTree() if selectedNode == nil { return } path := selectedNode.path if self.InTreeMode() { self.ExpandToPath(path) } else if len(selectedNode.Children) > 0 { path = selectedNode.GetLeaves()[0].path } index, found := self.GetIndexForPath(path) if found { self.SetSelection(index) } } func (self *CommitFileTreeViewModel) CollapseAll() { selectedNode := self.GetSelected() self.ICommitFileTree.CollapseAll() if selectedNode == nil { return } topLevelPath := strings.Split(selectedNode.path, "/")[0] index, found := self.GetIndexForPath(topLevelPath) if found { self.SetSelectedLineIdx(index) } } func (self *CommitFileTreeViewModel) ExpandAll() { selectedNode := self.GetSelected() self.ICommitFileTree.ExpandAll() if selectedNode == nil { return } index, found := self.GetIndexForPath(selectedNode.path) if found { self.SetSelectedLineIdx(index) } } lazygit-0.50.0+ds1/pkg/gui/filetree/file_node.go000066400000000000000000000031601500612110400213470ustar00rootroot00000000000000package filetree import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts" ) // FileNode wraps a node and provides some file-specific methods for it. type FileNode struct { *Node[models.File] } var _ models.IFile = &FileNode{} func NewFileNode(node *Node[models.File]) *FileNode { if node == nil { return nil } return &FileNode{Node: node} } // returns the underlying node, without any file-specific methods attached func (self *FileNode) Raw() *Node[models.File] { if self == nil { return nil } return self.Node } func (self *FileNode) GetHasUnstagedChanges() bool { return self.SomeFile(func(file *models.File) bool { return file.HasUnstagedChanges }) } func (self *FileNode) GetHasStagedOrTrackedChanges() bool { if !self.GetHasStagedChanges() { return self.SomeFile(func(t *models.File) bool { return t.Tracked }) } return true } func (self *FileNode) GetHasStagedChanges() bool { return self.SomeFile(func(file *models.File) bool { return file.HasStagedChanges }) } func (self *FileNode) GetHasInlineMergeConflicts() bool { return self.SomeFile(func(file *models.File) bool { if !file.HasInlineMergeConflicts { return false } hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path) return hasConflicts }) } func (self *FileNode) GetIsTracked() bool { return self.SomeFile(func(file *models.File) bool { return file.Tracked }) } func (self *FileNode) GetIsFile() bool { return self.IsFile() } func (self *FileNode) GetPreviousPath() string { if self.File == nil { return "" } return self.File.PreviousPath } lazygit-0.50.0+ds1/pkg/gui/filetree/file_node_test.go000066400000000000000000000074161500612110400224160ustar00rootroot00000000000000package filetree import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/stretchr/testify/assert" ) func TestCompress(t *testing.T) { scenarios := []struct { name string root *Node[models.File] expected *Node[models.File] }{ { name: "nil node", root: nil, expected: nil, }, { name: "leaf node", root: &Node[models.File]{ path: "", Children: []*Node[models.File]{ {File: &models.File{Path: "test", ShortStatus: " M", HasStagedChanges: true}, path: "test"}, }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ {File: &models.File{Path: "test", ShortStatus: " M", HasStagedChanges: true}, path: "test"}, }, }, }, { name: "big example", root: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { path: "dir1", Children: []*Node[models.File]{ { File: &models.File{Path: "file2", ShortStatus: "M ", HasUnstagedChanges: true}, path: "dir1/file2", }, }, }, { path: "dir2", Children: []*Node[models.File]{ { File: &models.File{Path: "file3", ShortStatus: " M", HasStagedChanges: true}, path: "dir2/file3", }, { File: &models.File{Path: "file4", ShortStatus: "M ", HasUnstagedChanges: true}, path: "dir2/file4", }, }, }, { path: "dir3", Children: []*Node[models.File]{ { path: "dir3/dir3-1", Children: []*Node[models.File]{ { File: &models.File{Path: "file5", ShortStatus: "M ", HasUnstagedChanges: true}, path: "dir3/dir3-1/file5", }, }, }, }, }, { File: &models.File{Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true}, path: "file1", }, }, }, expected: &Node[models.File]{ path: "", Children: []*Node[models.File]{ { path: "dir1", Children: []*Node[models.File]{ { File: &models.File{Path: "file2", ShortStatus: "M ", HasUnstagedChanges: true}, path: "dir1/file2", }, }, }, { path: "dir2", Children: []*Node[models.File]{ { File: &models.File{Path: "file3", ShortStatus: " M", HasStagedChanges: true}, path: "dir2/file3", }, { File: &models.File{Path: "file4", ShortStatus: "M ", HasUnstagedChanges: true}, path: "dir2/file4", }, }, }, { path: "dir3/dir3-1", CompressionLevel: 1, Children: []*Node[models.File]{ { File: &models.File{Path: "file5", ShortStatus: "M ", HasUnstagedChanges: true}, path: "dir3/dir3-1/file5", }, }, }, { File: &models.File{Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true}, path: "file1", }, }, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { s.root.Compress() assert.EqualValues(t, s.expected, s.root) }) } } func TestGetFile(t *testing.T) { scenarios := []struct { name string viewModel *FileTree path string expected *models.File }{ { name: "valid case", viewModel: NewFileTree(func() []*models.File { return []*models.File{{Path: "blah/one"}, {Path: "blah/two"}} }, nil, false), path: "blah/two", expected: &models.File{Path: "blah/two"}, }, { name: "not found", viewModel: NewFileTree(func() []*models.File { return []*models.File{{Path: "blah/one"}, {Path: "blah/two"}} }, nil, false), path: "blah/three", expected: nil, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { assert.EqualValues(t, s.expected, s.viewModel.GetFile(s.path)) }) } } lazygit-0.50.0+ds1/pkg/gui/filetree/file_tree.go000066400000000000000000000127531500612110400213710ustar00rootroot00000000000000package filetree import ( "fmt" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" "github.com/sirupsen/logrus" ) type FileTreeDisplayFilter int const ( DisplayAll FileTreeDisplayFilter = iota DisplayStaged DisplayUnstaged DisplayTracked DisplayUntracked // this shows files with merge conflicts DisplayConflicted ) type ITree[T any] interface { InTreeMode() bool ExpandToPath(path string) ToggleShowTree() GetIndexForPath(path string) (int, bool) Len() int GetItem(index int) types.HasUrn SetTree() IsCollapsed(path string) bool ToggleCollapsed(path string) CollapsedPaths() *CollapsedPaths CollapseAll() ExpandAll() } type IFileTree interface { ITree[models.File] FilterFiles(test func(*models.File) bool) []*models.File SetStatusFilter(filter FileTreeDisplayFilter) ForceShowUntracked() bool Get(index int) *FileNode GetFile(path string) *models.File GetAllItems() []*FileNode GetAllFiles() []*models.File GetFilter() FileTreeDisplayFilter GetRoot() *FileNode } type FileTree struct { getFiles func() []*models.File tree *Node[models.File] showTree bool log *logrus.Entry filter FileTreeDisplayFilter collapsedPaths *CollapsedPaths } var _ IFileTree = &FileTree{} func NewFileTree(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTree { return &FileTree{ getFiles: getFiles, log: log, showTree: showTree, filter: DisplayAll, collapsedPaths: NewCollapsedPaths(), } } func (self *FileTree) InTreeMode() bool { return self.showTree } func (self *FileTree) ExpandToPath(path string) { self.collapsedPaths.ExpandToPath(path) } func (self *FileTree) getFilesForDisplay() []*models.File { switch self.filter { case DisplayAll: return self.getFiles() case DisplayStaged: return self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges }) case DisplayUnstaged: return self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges }) case DisplayTracked: // untracked but staged files are technically not tracked by git // but including such files in the filtered mode helps see what files are getting committed return self.FilterFiles(func(file *models.File) bool { return file.Tracked || file.HasStagedChanges }) case DisplayUntracked: return self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) }) case DisplayConflicted: return self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts }) default: panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter)) } } func (self *FileTree) ForceShowUntracked() bool { return self.filter == DisplayUntracked } func (self *FileTree) FilterFiles(test func(*models.File) bool) []*models.File { return lo.Filter(self.getFiles(), func(file *models.File, _ int) bool { return test(file) }) } func (self *FileTree) SetStatusFilter(filter FileTreeDisplayFilter) { self.filter = filter self.SetTree() } func (self *FileTree) ToggleShowTree() { self.showTree = !self.showTree self.SetTree() } func (self *FileTree) Get(index int) *FileNode { // need to traverse the tree depth first until we get to the index. return NewFileNode(self.tree.GetNodeAtIndex(index+1, self.collapsedPaths)) // ignoring root } func (self *FileTree) GetFile(path string) *models.File { for _, file := range self.getFiles() { if file.Path == path { return file } } return nil } func (self *FileTree) GetIndexForPath(path string) (int, bool) { index, found := self.tree.GetIndexForPath(path, self.collapsedPaths) return index - 1, found } // note: this gets all items when the filter is taken into consideration. There may // be hidden files that aren't included here. Files off the screen however will // be included func (self *FileTree) GetAllItems() []*FileNode { if self.tree == nil { return nil } // ignoring root return lo.Map(self.tree.Flatten(self.collapsedPaths)[1:], func(node *Node[models.File], _ int) *FileNode { return NewFileNode(node) }) } func (self *FileTree) Len() int { // -1 because we're ignoring the root return max(self.tree.Size(self.collapsedPaths)-1, 0) } func (self *FileTree) GetItem(index int) types.HasUrn { // Unimplemented because we don't yet need to show inlines statuses in commit file views return nil } func (self *FileTree) GetAllFiles() []*models.File { return self.getFiles() } func (self *FileTree) SetTree() { filesForDisplay := self.getFilesForDisplay() if self.showTree { self.tree = BuildTreeFromFiles(filesForDisplay) } else { self.tree = BuildFlatTreeFromFiles(filesForDisplay) } } func (self *FileTree) IsCollapsed(path string) bool { return self.collapsedPaths.IsCollapsed(path) } func (self *FileTree) ToggleCollapsed(path string) { self.collapsedPaths.ToggleCollapsed(path) } func (self *FileTree) CollapseAll() { dirPaths := lo.FilterMap(self.GetAllItems(), func(file *FileNode, index int) (string, bool) { return file.path, !file.IsFile() }) for _, path := range dirPaths { self.collapsedPaths.Collapse(path) } } func (self *FileTree) ExpandAll() { self.collapsedPaths.ExpandAll() } func (self *FileTree) Tree() *FileNode { return NewFileNode(self.tree) } func (self *FileTree) GetRoot() *FileNode { return NewFileNode(self.tree) } func (self *FileTree) CollapsedPaths() *CollapsedPaths { return self.collapsedPaths } func (self *FileTree) GetFilter() FileTreeDisplayFilter { return self.filter } lazygit-0.50.0+ds1/pkg/gui/filetree/file_tree_test.go000066400000000000000000000061721500612110400224260ustar00rootroot00000000000000package filetree import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/stretchr/testify/assert" ) func TestFilterAction(t *testing.T) { scenarios := []struct { name string filter FileTreeDisplayFilter files []*models.File expected []*models.File }{ { name: "filter files with unstaged changes", filter: DisplayUnstaged, files: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "dir2/file5", ShortStatus: "M ", HasStagedChanges: true}, {Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true}, }, expected: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true}, }, }, { name: "filter files with staged changes", filter: DisplayStaged, files: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true}, {Path: "dir2/file5", ShortStatus: "M ", HasStagedChanges: false}, {Path: "file1", ShortStatus: "M ", HasStagedChanges: true}, }, expected: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true}, {Path: "file1", ShortStatus: "M ", HasStagedChanges: true}, }, }, { name: "filter files that are tracked", filter: DisplayTracked, files: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "M ", Tracked: true}, {Path: "dir2/file5", ShortStatus: "M ", Tracked: false}, {Path: "file1", ShortStatus: "M ", Tracked: true}, }, expected: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "M ", Tracked: true}, {Path: "file1", ShortStatus: "M ", Tracked: true}, }, }, { name: "filter all files", filter: DisplayAll, files: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true}, }, expected: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true}, }, }, { name: "filter conflicted files", filter: DisplayConflicted, files: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "DU", HasMergeConflicts: true}, {Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "dir2/file6", ShortStatus: " M", HasStagedChanges: true}, {Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true}, }, expected: []*models.File{ {Path: "dir2/dir2/file4", ShortStatus: "DU", HasMergeConflicts: true}, {Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true}, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { mngr := &FileTree{getFiles: func() []*models.File { return s.files }, filter: s.filter} result := mngr.getFilesForDisplay() assert.EqualValues(t, s.expected, result) }) } } lazygit-0.50.0+ds1/pkg/gui/filetree/file_tree_view_model.go000066400000000000000000000131761500612110400236030ustar00rootroot00000000000000package filetree import ( "strings" "sync" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sirupsen/logrus" ) type IFileTreeViewModel interface { IFileTree types.IListCursor } // This combines our FileTree struct with a cursor that retains information about // which item is selected. It also contains logic for repositioning that cursor // after the files are refreshed type FileTreeViewModel struct { sync.RWMutex types.IListCursor IFileTree } var _ IFileTreeViewModel = &FileTreeViewModel{} func NewFileTreeViewModel(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel { fileTree := NewFileTree(getFiles, log, showTree) listCursor := traits.NewListCursor(fileTree.Len) return &FileTreeViewModel{ IFileTree: fileTree, IListCursor: listCursor, } } func (self *FileTreeViewModel) GetSelected() *FileNode { if self.Len() == 0 { return nil } return self.Get(self.GetSelectedLineIdx()) } func (self *FileTreeViewModel) GetSelectedItemId() string { item := self.GetSelected() if item == nil { return "" } return item.ID() } func (self *FileTreeViewModel) GetSelectedItems() ([]*FileNode, int, int) { if self.Len() == 0 { return nil, 0, 0 } startIdx, endIdx := self.GetSelectionRange() nodes := []*FileNode{} for i := startIdx; i <= endIdx; i++ { nodes = append(nodes, self.Get(i)) } return nodes, startIdx, endIdx } func (self *FileTreeViewModel) GetSelectedItemIds() ([]string, int, int) { selectedItems, startIdx, endIdx := self.GetSelectedItems() ids := lo.Map(selectedItems, func(item *FileNode, _ int) string { return item.ID() }) return ids, startIdx, endIdx } func (self *FileTreeViewModel) GetSelectedFile() *models.File { node := self.GetSelected() if node == nil { return nil } return node.File } func (self *FileTreeViewModel) GetSelectedPath() string { node := self.GetSelected() if node == nil { return "" } return node.GetPath() } func (self *FileTreeViewModel) SetTree() { newFiles := self.GetAllFiles() selectedNode := self.GetSelected() // for when you stage the old file of a rename and the new file is in a collapsed dir for _, file := range newFiles { if selectedNode != nil && selectedNode.path != "" && file.PreviousPath == selectedNode.path { self.ExpandToPath(file.Path) } } prevNodes := self.GetAllItems() prevSelectedLineIdx := self.GetSelectedLineIdx() self.IFileTree.SetTree() if selectedNode != nil { newNodes := self.GetAllItems() newIdx := self.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], newNodes) if newIdx != -1 && newIdx != prevSelectedLineIdx { self.SetSelection(newIdx) } } self.ClampSelection() } // Let's try to find our file again and move the cursor to that. // If we can't find our file, it was probably just removed by the user. In that // case, we go looking for where the next file has been moved to. Given that the // user could have removed a whole directory, we continue iterating through the old // nodes until we find one that exists in the new set of nodes, then move the cursor // to that. // prevNodes starts from our previously selected node because we don't need to consider anything above that func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNodes []*FileNode) int { getPaths := func(node *FileNode) []string { if node == nil { return nil } if node.File != nil && node.File.IsRename() { return node.File.Names() } else { return []string{node.path} } } for _, prevNode := range prevNodes { selectedPaths := getPaths(prevNode) for idx, node := range currNodes { paths := getPaths(node) // If you started off with a rename selected, and now it's broken in two, we want you to jump to the new file, not the old file. // This is because the new should be in the same position as the rename was meaning less cursor jumping foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.path == prevNode.File.PreviousPath foundNode := utils.StringArraysOverlap(paths, selectedPaths) && !foundOldFileInRename if foundNode { return idx } } } return -1 } func (self *FileTreeViewModel) SetStatusFilter(filter FileTreeDisplayFilter) { self.IFileTree.SetStatusFilter(filter) self.IListCursor.SetSelection(0) } // If we're going from flat to tree we want to select the same file. // If we're going from tree to flat and we have a file selected we want to select that. // If instead we've selected a directory we need to select the first file in that directory. func (self *FileTreeViewModel) ToggleShowTree() { selectedNode := self.GetSelected() self.IFileTree.ToggleShowTree() if selectedNode == nil { return } path := selectedNode.path if self.InTreeMode() { self.ExpandToPath(path) } else if len(selectedNode.Children) > 0 { path = selectedNode.GetLeaves()[0].path } index, found := self.GetIndexForPath(path) if found { self.SetSelectedLineIdx(index) } } func (self *FileTreeViewModel) CollapseAll() { selectedNode := self.GetSelected() self.IFileTree.CollapseAll() if selectedNode == nil { return } topLevelPath := strings.Split(selectedNode.path, "/")[0] index, found := self.GetIndexForPath(topLevelPath) if found { self.SetSelectedLineIdx(index) } } func (self *FileTreeViewModel) ExpandAll() { selectedNode := self.GetSelected() self.IFileTree.ExpandAll() if selectedNode == nil { return } index, found := self.GetIndexForPath(selectedNode.path) if found { self.SetSelectedLineIdx(index) } } lazygit-0.50.0+ds1/pkg/gui/filetree/node.go000066400000000000000000000154301500612110400203530ustar00rootroot00000000000000package filetree import ( "path" "slices" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) // Represents a file or directory in a file tree. type Node[T any] struct { // File will be nil if the node is a directory. File *T // If the node is a directory, Children contains the contents of the directory, // otherwise it's nil. Children []*Node[T] // path of the file/directory // private; use either GetPath() or GetInternalPath() to access path string // rather than render a tree as: // a/ // b/ // file.blah // // we instead render it as: // a/b/ // file.blah // This saves vertical space. The CompressionLevel of a node is equal to the // number of times a 'compression' like the above has happened, where two // nodes are squished into one. CompressionLevel int } var _ types.ListItem = &Node[models.File]{} func (self *Node[T]) IsFile() bool { return self.File != nil } func (self *Node[T]) GetFile() *T { return self.File } // This returns the logical path from the user's point of view. It is the // relative path from the root of the repository. // Use this for display, or when you want to perform some action on the path // (e.g. a git command). func (self *Node[T]) GetPath() string { return strings.TrimPrefix(self.path, "./") } // This returns the internal path from the tree's point of view. It's the same // as GetPath(), but prefixed with "./" for the root item. // Use this when interacting with the tree itself, e.g. when calling // ToggleCollapsed. func (self *Node[T]) GetInternalPath() string { return self.path } func (self *Node[T]) Sort() { self.SortChildren() for _, child := range self.Children { child.Sort() } } func (self *Node[T]) ForEachFile(cb func(*T) error) error { if self.IsFile() { if err := cb(self.File); err != nil { return err } } for _, child := range self.Children { if err := child.ForEachFile(cb); err != nil { return err } } return nil } func (self *Node[T]) SortChildren() { if self.IsFile() { return } children := slices.Clone(self.Children) slices.SortFunc(children, func(a, b *Node[T]) int { if !a.IsFile() && b.IsFile() { return -1 } if a.IsFile() && !b.IsFile() { return 1 } return strings.Compare(a.path, b.path) }) // TODO: think about making this in-place self.Children = children } func (self *Node[T]) Some(predicate func(*Node[T]) bool) bool { if predicate(self) { return true } for _, child := range self.Children { if child.Some(predicate) { return true } } return false } func (self *Node[T]) SomeFile(predicate func(*T) bool) bool { if self.IsFile() { if predicate(self.File) { return true } } else { for _, child := range self.Children { if child.SomeFile(predicate) { return true } } } return false } func (self *Node[T]) Every(predicate func(*Node[T]) bool) bool { if !predicate(self) { return false } for _, child := range self.Children { if !child.Every(predicate) { return false } } return true } func (self *Node[T]) EveryFile(predicate func(*T) bool) bool { if self.IsFile() { if !predicate(self.File) { return false } } else { for _, child := range self.Children { if !child.EveryFile(predicate) { return false } } } return true } func (self *Node[T]) FindFirstFileBy(predicate func(*T) bool) *T { if self.IsFile() { if predicate(self.File) { return self.File } } else { for _, child := range self.Children { if file := child.FindFirstFileBy(predicate); file != nil { return file } } } return nil } func (self *Node[T]) Flatten(collapsedPaths *CollapsedPaths) []*Node[T] { result := []*Node[T]{self} if len(self.Children) > 0 && !collapsedPaths.IsCollapsed(self.path) { result = append(result, lo.FlatMap(self.Children, func(child *Node[T], _ int) []*Node[T] { return child.Flatten(collapsedPaths) })...) } return result } func (self *Node[T]) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *Node[T] { if self == nil { return nil } node, _ := self.getNodeAtIndexAux(index, collapsedPaths) return node } func (self *Node[T]) getNodeAtIndexAux(index int, collapsedPaths *CollapsedPaths) (*Node[T], int) { offset := 1 if index == 0 { return self, offset } if !collapsedPaths.IsCollapsed(self.path) { for _, child := range self.Children { foundNode, offsetChange := child.getNodeAtIndexAux(index-offset, collapsedPaths) offset += offsetChange if foundNode != nil { return foundNode, offset } } } return nil, offset } func (self *Node[T]) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) { offset := 0 if self.path == path { return offset, true } if !collapsedPaths.IsCollapsed(self.path) { for _, child := range self.Children { offsetChange, found := child.GetIndexForPath(path, collapsedPaths) offset += offsetChange + 1 if found { return offset, true } } } return offset, false } func (self *Node[T]) Size(collapsedPaths *CollapsedPaths) int { if self == nil { return 0 } output := 1 if !collapsedPaths.IsCollapsed(self.path) { for _, child := range self.Children { output += child.Size(collapsedPaths) } } return output } func (self *Node[T]) Compress() { if self == nil { return } self.compressAux() } func (self *Node[T]) compressAux() *Node[T] { if self.IsFile() { return self } children := self.Children for i := range children { grandchildren := children[i].Children for len(grandchildren) == 1 && !grandchildren[0].IsFile() { grandchildren[0].CompressionLevel = children[i].CompressionLevel + 1 children[i] = grandchildren[0] grandchildren = children[i].Children } } for i := range children { children[i] = children[i].compressAux() } self.Children = children return self } func (self *Node[T]) GetPathsMatching(predicate func(*Node[T]) bool) []string { paths := []string{} if predicate(self) { paths = append(paths, self.GetPath()) } for _, child := range self.Children { paths = append(paths, child.GetPathsMatching(predicate)...) } return paths } func (self *Node[T]) GetFilePathsMatching(predicate func(*T) bool) []string { matchingFileNodes := lo.Filter(self.GetLeaves(), func(node *Node[T], _ int) bool { return predicate(node.File) }) return lo.Map(matchingFileNodes, func(node *Node[T], _ int) string { return node.GetPath() }) } func (self *Node[T]) GetLeaves() []*Node[T] { if self.IsFile() { return []*Node[T]{self} } return lo.FlatMap(self.Children, func(child *Node[T], _ int) []*Node[T] { return child.GetLeaves() }) } func (self *Node[T]) ID() string { return self.GetPath() } func (self *Node[T]) Description() string { return self.GetPath() } func (self *Node[T]) Name() string { return path.Base(self.path) } lazygit-0.50.0+ds1/pkg/gui/global_handlers.go000066400000000000000000000131041500612110400207430ustar00rootroot00000000000000package gui import ( "fmt" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) const HORIZONTAL_SCROLL_FACTOR = 3 func (gui *Gui) scrollUpView(view *gocui.View) { view.ScrollUp(gui.c.UserConfig().Gui.ScrollHeight) } func (gui *Gui) scrollDownView(view *gocui.View) { scrollHeight := gui.c.UserConfig().Gui.ScrollHeight view.ScrollDown(scrollHeight) if manager := gui.getViewBufferManagerForView(view); manager != nil { manager.ReadLines(scrollHeight) } } func (gui *Gui) scrollUpMain() error { var view *gocui.View if gui.c.Context().Current().GetWindowName() == "secondary" { view = gui.secondaryView() } else { view = gui.mainView() } if view.Name() == "mergeConflicts" { // although we have this same logic in the controller, this method can be invoked // via the global scroll up/down keybindings, as opposed to just the mouse wheel keybinding. // It would be nice to have a concept of a global keybinding that runs on the top context in a // window but that might be overkill for this one use case. gui.State.Contexts.MergeConflicts.SetUserScrolling(true) } gui.scrollUpView(view) return nil } func (gui *Gui) scrollDownMain() error { var view *gocui.View if gui.c.Context().Current().GetWindowName() == "secondary" { view = gui.secondaryView() } else { view = gui.mainView() } if view.Name() == "mergeConflicts" { gui.State.Contexts.MergeConflicts.SetUserScrolling(true) } gui.scrollDownView(view) return nil } func (gui *Gui) mainView() *gocui.View { viewName := gui.helpers.Window.GetViewNameForWindow("main") view, _ := gui.g.View(viewName) return view } func (gui *Gui) secondaryView() *gocui.View { viewName := gui.helpers.Window.GetViewNameForWindow("secondary") view, _ := gui.g.View(viewName) return view } func (gui *Gui) scrollUpSecondary() error { gui.scrollUpView(gui.secondaryView()) return nil } func (gui *Gui) scrollDownSecondary() error { secondaryView := gui.secondaryView() gui.scrollDownView(secondaryView) return nil } func (gui *Gui) scrollUpConfirmationPanel() error { if gui.Views.Confirmation.Editable { return nil } gui.scrollUpView(gui.Views.Confirmation) return nil } func (gui *Gui) scrollDownConfirmationPanel() error { if gui.Views.Confirmation.Editable { return nil } gui.scrollDownView(gui.Views.Confirmation) return nil } func (gui *Gui) pageUpConfirmationPanel() error { if gui.Views.Confirmation.Editable { return nil } gui.Views.Confirmation.ScrollUp(gui.Contexts().Confirmation.GetViewTrait().PageDelta()) return nil } func (gui *Gui) pageDownConfirmationPanel() error { if gui.Views.Confirmation.Editable { return nil } gui.Views.Confirmation.ScrollDown(gui.Contexts().Confirmation.GetViewTrait().PageDelta()) return nil } func (gui *Gui) goToConfirmationPanelTop() error { if gui.Views.Confirmation.Editable { return nil } gui.Views.Confirmation.ScrollUp(gui.Views.Confirmation.ViewLinesHeight()) return nil } func (gui *Gui) goToConfirmationPanelBottom() error { if gui.Views.Confirmation.Editable { return nil } gui.Views.Confirmation.ScrollDown(gui.Views.Confirmation.ViewLinesHeight()) return nil } func (gui *Gui) handleCopySelectedSideContextItemToClipboard() error { return gui.handleCopySelectedSideContextItemToClipboardWithTruncation(-1) } func (gui *Gui) handleCopySelectedSideContextItemCommitHashToClipboard() error { return gui.handleCopySelectedSideContextItemToClipboardWithTruncation( gui.UserConfig().Git.TruncateCopiedCommitHashesTo) } func (gui *Gui) handleCopySelectedSideContextItemToClipboardWithTruncation(maxWidth int) error { // important to note that this assumes we've selected an item in a side context currentSideContext := gui.c.Context().CurrentSide() if currentSideContext == nil { return nil } listContext, ok := currentSideContext.(types.IListContext) if !ok { return nil } itemId := listContext.GetSelectedItemId() if itemId == "" { return nil } if maxWidth > 0 { itemId = itemId[:min(len(itemId), maxWidth)] } gui.c.LogAction(gui.c.Tr.Actions.CopyToClipboard) if err := gui.os.CopyToClipboard(itemId); err != nil { return err } truncatedItemId := utils.TruncateWithEllipsis(strings.Replace(itemId, "\n", " ", -1), 50) gui.c.Toast(fmt.Sprintf("'%s' %s", truncatedItemId, gui.c.Tr.CopiedToClipboard)) return nil } func (gui *Gui) getCopySelectedSideContextItemToClipboardDisabledReason() *types.DisabledReason { // important to note that this assumes we've selected an item in a side context currentSideContext := gui.c.Context().CurrentSide() if currentSideContext == nil { // This should never happen but if it does we'll just ignore the keypress return nil } listContext, ok := currentSideContext.(types.IListContext) if !ok { // This should never happen but if it does we'll just ignore the keypress return nil } startIdx, endIdx := listContext.GetList().GetSelectionRange() if startIdx != endIdx { return &types.DisabledReason{Text: gui.Tr.RangeSelectNotSupported} } return nil } func (gui *Gui) setCaption(caption string) { gui.Views.Options.FgColor = gocui.ColorWhite gui.Views.Options.FgColor |= gocui.AttrBold gui.Views.Options.SetContent(captionPrefix + " " + style.FgCyan.SetBold().Sprint(caption)) gui.c.Render() } var captionPrefix = "" func (gui *Gui) setCaptionPrefix(prefix string) { gui.Views.Options.FgColor = gocui.ColorWhite gui.Views.Options.FgColor |= gocui.AttrBold captionPrefix = prefix gui.Views.Options.SetContent(prefix) gui.c.Render() } lazygit-0.50.0+ds1/pkg/gui/gui.go000066400000000000000000001011341500612110400164100ustar00rootroot00000000000000package gui import ( goContext "context" "fmt" "io" "os" "path/filepath" "reflect" "regexp" "sort" "strings" "sync" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/boxlayout" appTypes "github.com/jesseduffield/lazygit/pkg/app/types" "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking" "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing" "github.com/jesseduffield/lazygit/pkg/gui/modes/filtering" "github.com/jesseduffield/lazygit/pkg/gui/modes/marked_base_commit" "github.com/jesseduffield/lazygit/pkg/gui/popup" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/presentation/authors" "github.com/jesseduffield/lazygit/pkg/gui/presentation/graph" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands" "github.com/jesseduffield/lazygit/pkg/gui/status" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/integration/components" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/updates" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) const StartupPopupVersion = 5 // OverlappingEdges determines if panel edges overlap var OverlappingEdges = false type Repo string // Gui wraps the gocui Gui object which handles rendering and events type Gui struct { *common.Common g *gocui.Gui gitVersion *git_commands.GitVersion git *commands.GitCommand os *oscommands.OSCommand // this is the state of the GUI for the current repo State *GuiRepoState CustomCommandsClient *custom_commands.Client // this is a mapping of repos to gui states, so that we can restore the original // gui state when returning from a subrepo. // In repos with multiple worktrees, we store a separate repo state per worktree. RepoStateMap map[Repo]*GuiRepoState Config config.AppConfigurer Updater *updates.Updater statusManager *status.StatusManager waitForIntro sync.WaitGroup viewBufferManagerMap map[string]*tasks.ViewBufferManager // holds a mapping of view names to ptmx's. This is for rendering command outputs // from within a pty. The point of keeping track of them is so that if we re-size // the window, we can tell the pty it needs to resize accordingly. viewPtmxMap map[string]*os.File stopChan chan struct{} // when lazygit is opened outside a git directory we want to open to the most // recent repo with the recent repos popup showing showRecentRepos bool Mutexes types.Mutexes // when you enter into a submodule we'll append the superproject's path to this array // so that you can return to the superproject RepoPathStack *utils.StringStack // this tells us whether our views have been initially set up ViewsSetup bool Views types.Views // Log of the commands/actions logged in the Command Log panel. GuiLog []string // the extras window contains things like the command log ShowExtrasWindow bool PopupHandler types.IPopupHandler IsRefreshingFiles bool // we use this to decide whether we'll return to the original directory that // lazygit was opened in, or if we'll retain the one we're currently in. RetainOriginalDir bool // stores long-running operations associated with items (e.g. when a branch // is being pushed). At the moment the rule is to use an item operation when // we need to talk to the remote. itemOperations map[string]types.ItemOperation itemOperationsMutex *deadlock.Mutex PrevLayout PrevLayout // this is the initial dir we are in upon opening lazygit. We hold onto this // in case we want to restore it before quitting for users who have set up // the feature for changing directory upon quit. // The reason we don't just wait until quit time to handle changing directories // is because some users want to keep track of the current lazygit directory in an outside // process InitialDir string BackgroundRoutineMgr *BackgroundRoutineMgr // for accessing the gui's state from outside this package stateAccessor *StateAccessor Updating bool c *helpers.HelperCommon helpers *helpers.Helpers previousLanguageConfig string integrationTest integrationTypes.IntegrationTest afterLayoutFuncs chan func() error } type StateAccessor struct { gui *Gui } var _ types.IStateAccessor = new(StateAccessor) func (self *StateAccessor) GetRepoPathStack() *utils.StringStack { return self.gui.RepoPathStack } func (self *StateAccessor) GetUpdating() bool { return self.gui.Updating } func (self *StateAccessor) SetUpdating(value bool) { self.gui.Updating = value } func (self *StateAccessor) GetRepoState() types.IRepoStateAccessor { return self.gui.State } func (self *StateAccessor) GetIsRefreshingFiles() bool { return self.gui.IsRefreshingFiles } func (self *StateAccessor) SetIsRefreshingFiles(value bool) { self.gui.IsRefreshingFiles = value } func (self *StateAccessor) GetShowExtrasWindow() bool { return self.gui.ShowExtrasWindow } func (self *StateAccessor) SetShowExtrasWindow(value bool) { self.gui.ShowExtrasWindow = value } func (self *StateAccessor) GetRetainOriginalDir() bool { return self.gui.RetainOriginalDir } func (self *StateAccessor) SetRetainOriginalDir(value bool) { self.gui.RetainOriginalDir = value } func (self *StateAccessor) GetItemOperation(item types.HasUrn) types.ItemOperation { self.gui.itemOperationsMutex.Lock() defer self.gui.itemOperationsMutex.Unlock() return self.gui.itemOperations[item.URN()] } func (self *StateAccessor) SetItemOperation(item types.HasUrn, operation types.ItemOperation) { self.gui.itemOperationsMutex.Lock() defer self.gui.itemOperationsMutex.Unlock() self.gui.itemOperations[item.URN()] = operation } func (self *StateAccessor) ClearItemOperation(item types.HasUrn) { self.gui.itemOperationsMutex.Lock() defer self.gui.itemOperationsMutex.Unlock() delete(self.gui.itemOperations, item.URN()) } // we keep track of some stuff from one render to the next to see if certain // things have changed type PrevLayout struct { Information string MainWidth int MainHeight int } type GuiRepoState struct { Model *types.Model Modes *types.Modes SplitMainPanel bool LimitCommits bool SearchState *types.SearchState StartupStage types.StartupStage // Allows us to not load everything at once ContextMgr *ContextMgr Contexts *context.ContextTree // WindowViewNameMap is a mapping of windows to the current view of that window. // Some views move between windows for example the commitFiles view and when cycling through // side windows we need to know which view to give focus to for a given window WindowViewNameMap *utils.ThreadSafeMap[string, string] // tells us whether we've set up our views for the current repo. We'll need to // do this whenever we switch back and forth between repos to get the views // back in sync with the repo state ViewsSetup bool ScreenMode types.ScreenMode CurrentPopupOpts *types.CreatePopupPanelOpts } var _ types.IRepoStateAccessor = new(GuiRepoState) func (self *GuiRepoState) GetViewsSetup() bool { return self.ViewsSetup } func (self *GuiRepoState) GetWindowViewNameMap() *utils.ThreadSafeMap[string, string] { return self.WindowViewNameMap } func (self *GuiRepoState) GetStartupStage() types.StartupStage { return self.StartupStage } func (self *GuiRepoState) SetStartupStage(value types.StartupStage) { self.StartupStage = value } func (self *GuiRepoState) GetCurrentPopupOpts() *types.CreatePopupPanelOpts { return self.CurrentPopupOpts } func (self *GuiRepoState) SetCurrentPopupOpts(value *types.CreatePopupPanelOpts) { self.CurrentPopupOpts = value } func (self *GuiRepoState) GetScreenMode() types.ScreenMode { return self.ScreenMode } func (self *GuiRepoState) SetScreenMode(value types.ScreenMode) { self.ScreenMode = value } func (self *GuiRepoState) InSearchPrompt() bool { return self.SearchState.SearchType() != types.SearchTypeNone } func (self *GuiRepoState) GetSearchState() *types.SearchState { return self.SearchState } func (self *GuiRepoState) SetSplitMainPanel(value bool) { self.SplitMainPanel = value } func (self *GuiRepoState) GetSplitMainPanel() bool { return self.SplitMainPanel } func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error { var err error gui.git, err = commands.NewGitCommand( gui.Common, gui.gitVersion, gui.os, git_config.NewStdCachedGitConfig(gui.Log), ) if err != nil { return err } err = gui.Config.ReloadUserConfigForRepo(gui.getPerRepoConfigFiles()) if err != nil { return err } err = gui.onUserConfigLoaded() if err != nil { return err } contextToPush := gui.resetState(startArgs) gui.resetHelpersAndControllers() if err := gui.resetKeybindings(); err != nil { return err } gui.g.SetFocusHandler(func(Focused bool) error { if Focused { gui.git.Config.DropConfigCache() oldConfig := gui.Config.GetUserConfig() reloadErr, didChange := gui.Config.ReloadChangedUserConfigFiles() if didChange && reloadErr == nil { gui.c.Log.Info("User config changed - reloading") reloadErr = gui.onUserConfigLoaded() if err := gui.resetKeybindings(); err != nil { return err } if err := gui.checkForChangedConfigsThatDontAutoReload(oldConfig, gui.Config.GetUserConfig()); err != nil { return err } } gui.c.Log.Info("Receiving focus - refreshing") refreshErr := gui.helpers.Refresh.Refresh(types.RefreshOptions{Mode: types.ASYNC}) if reloadErr != nil { // An error from reloading the config is the more important one // to report to the user return reloadErr } return refreshErr } return nil }) gui.g.SetOpenHyperlinkFunc(func(url string, viewname string) error { if strings.HasPrefix(url, "lazygit-edit:") { re := regexp.MustCompile(`^lazygit-edit://(.+?)(?::(\d+))?$`) matches := re.FindStringSubmatch(url) if matches == nil { return fmt.Errorf(gui.Tr.InvalidLazygitEditURL, url) } filepath := matches[1] if matches[2] != "" { lineNumber := utils.MustConvertToInt(matches[2]) lineNumber = gui.helpers.Diff.AdjustLineNumber(filepath, lineNumber, viewname) return gui.helpers.Files.EditFileAtLine(filepath, lineNumber) } return gui.helpers.Files.EditFiles([]string{filepath}) } if err := gui.os.OpenLink(url); err != nil { return fmt.Errorf(gui.Tr.FailedToOpenURL, url, err) } return nil }) // if a context key has been given, push that instead, and set its index to 0 if contextKey != context.NO_CONTEXT { contextToPush = gui.c.ContextForKey(contextKey) // when we pass a list context, the expectation is that our cursor goes to the top, // because e.g. with worktrees, we'll show the current worktree at the top of the list. listContext, ok := contextToPush.(types.IListContext) if ok { listContext.GetList().SetSelection(0) } } gui.c.Context().Push(contextToPush, types.OnFocusOpts{}) return nil } func (gui *Gui) getPerRepoConfigFiles() []*config.ConfigFile { repoConfigFiles := []*config.ConfigFile{ // TODO: add filepath.Join(gui.git.RepoPaths.RepoPath(), ".lazygit.yml"), // with trust prompt { Path: filepath.Join(gui.git.RepoPaths.RepoGitDirPath(), "lazygit.yml"), Policy: config.ConfigFilePolicySkipIfMissing, }, } prevDir := gui.c.Git().RepoPaths.RepoPath() dir := filepath.Dir(prevDir) for dir != prevDir { repoConfigFiles = utils.Prepend(repoConfigFiles, &config.ConfigFile{ Path: filepath.Join(dir, ".lazygit.yml"), Policy: config.ConfigFilePolicySkipIfMissing, }) prevDir = dir dir = filepath.Dir(dir) } return repoConfigFiles } func (gui *Gui) onUserConfigLoaded() error { userConfig := gui.Config.GetUserConfig() gui.Common.SetUserConfig(userConfig) if gui.previousLanguageConfig != userConfig.Gui.Language { tr, err := i18n.NewTranslationSetFromConfig(gui.Log, userConfig.Gui.Language) if err != nil { return err } gui.c.Tr = tr gui.previousLanguageConfig = userConfig.Gui.Language } gui.setColorScheme() gui.configureViewProperties() gui.g.SearchEscapeKey = keybindings.GetKey(userConfig.Keybinding.Universal.Return) gui.g.NextSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.NextMatch) gui.g.PrevSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.PrevMatch) gui.g.ShowListFooter = userConfig.Gui.ShowListFooter gui.g.Mouse = userConfig.Gui.MouseEvents // originally we could only hide the command log permanently via the config // but now we do it via state. So we need to still support the config for the // sake of backwards compatibility. We're making use of short circuiting here gui.ShowExtrasWindow = userConfig.Gui.ShowCommandLog && !gui.c.GetAppState().HideCommandLog authors.SetCustomAuthors(userConfig.Gui.AuthorColors) if userConfig.Gui.NerdFontsVersion != "" { icons.SetNerdFontsVersion(userConfig.Gui.NerdFontsVersion) } else if userConfig.Gui.ShowIcons { icons.SetNerdFontsVersion("2") } if len(userConfig.Gui.BranchColorPatterns) > 0 { presentation.SetCustomBranches(userConfig.Gui.BranchColorPatterns, true) } else { // Fall back to the deprecated branchColors config presentation.SetCustomBranches(userConfig.Gui.BranchColors, false) } return nil } func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserConfig, newConfig *config.UserConfig) error { configsThatDontAutoReload := []string{ "Git.AutoFetch", "Git.AutoRefresh", "Refresher.RefreshInterval", "Refresher.FetchInterval", "Update.Method", "Update.Days", } changedConfigs := []string{} for _, config := range configsThatDontAutoReload { old := reflect.ValueOf(oldConfig).Elem() new := reflect.ValueOf(newConfig).Elem() fieldNames := strings.Split(config, ".") userFacingPath := make([]string, 0, len(fieldNames)) // navigate to the leaves in old and new config for _, fieldName := range fieldNames { f, _ := old.Type().FieldByName(fieldName) userFacingName := f.Tag.Get("yaml") if userFacingName == "" { userFacingName = fieldName } userFacingPath = append(userFacingPath, userFacingName) old = old.FieldByName(fieldName) new = new.FieldByName(fieldName) } // if the value has changed, ... if !old.Equal(new) { // ... append it to the list of changed configs changedConfigs = append(changedConfigs, strings.Join(userFacingPath, ".")) } } if len(changedConfigs) == 0 { return nil } message := utils.ResolvePlaceholderString( gui.c.Tr.NonReloadableConfigWarning, map[string]string{ "configs": strings.Join(changedConfigs, "\n"), }, ) gui.c.Confirm(types.ConfirmOpts{ Title: gui.c.Tr.NonReloadableConfigWarningTitle, Prompt: message, }) return nil } // resetState reuses the repo state from our repo state map, if the repo was // open before; otherwise it creates a new one. func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { worktreePath := gui.git.RepoPaths.WorktreePath() if state := gui.RepoStateMap[Repo(worktreePath)]; state != nil { gui.State = state gui.State.ViewsSetup = false contextTree := gui.State.Contexts gui.State.WindowViewNameMap = initialWindowViewNameMap(contextTree) // setting this to nil so we don't get stuck based on a popup that was // previously opened gui.Mutexes.PopupMutex.Lock() gui.State.CurrentPopupOpts = nil gui.Mutexes.PopupMutex.Unlock() return gui.c.Context().Current() } contextTree := gui.contextTree() initialScreenMode := initialScreenMode(startArgs, gui.Config) gui.State = &GuiRepoState{ ViewsSetup: false, Model: &types.Model{ CommitFiles: nil, Files: make([]*models.File, 0), Commits: make([]*models.Commit, 0), StashEntries: make([]*models.StashEntry, 0), FilteredReflogCommits: make([]*models.Commit, 0), ReflogCommits: make([]*models.Commit, 0), BisectInfo: git_commands.NewNullBisectInfo(), FilesTrie: patricia.NewTrie(), Authors: map[string]*models.Author{}, MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd), HashPool: &utils.StringPool{}, }, Modes: &types.Modes{ Filtering: filtering.New(startArgs.FilterPath, ""), CherryPicking: cherrypicking.New(), Diffing: diffing.New(), MarkedBaseCommit: marked_base_commit.New(), }, ScreenMode: initialScreenMode, // TODO: only use contexts from context manager ContextMgr: NewContextMgr(gui, contextTree), Contexts: contextTree, WindowViewNameMap: initialWindowViewNameMap(contextTree), SearchState: types.NewSearchState(), } gui.RepoStateMap[Repo(worktreePath)] = gui.State return initialContext(contextTree, startArgs) } func (self *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager { manager, ok := self.viewBufferManagerMap[view.Name()] if !ok { return nil } return manager } func initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] { result := utils.NewThreadSafeMap[string, string]() for _, context := range contextTree.Flatten() { result.Set(context.GetWindowName(), context.GetViewName()) } return result } func initialScreenMode(startArgs appTypes.StartArgs, config config.AppConfigurer) types.ScreenMode { if startArgs.ScreenMode != "" { return parseScreenModeArg(startArgs.ScreenMode) } else if startArgs.FilterPath != "" || startArgs.GitArg != appTypes.GitArgNone { return types.SCREEN_HALF } else { return parseScreenModeArg(config.GetUserConfig().Gui.ScreenMode) } } func parseScreenModeArg(screenModeArg string) types.ScreenMode { switch screenModeArg { case "half": return types.SCREEN_HALF case "full": return types.SCREEN_FULL default: return types.SCREEN_NORMAL } } func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartArgs) types.IListContext { var initialContext types.IListContext = contextTree.Files if startArgs.FilterPath != "" { initialContext = contextTree.LocalCommits } else if startArgs.GitArg != appTypes.GitArgNone { switch startArgs.GitArg { case appTypes.GitArgStatus: initialContext = contextTree.Files case appTypes.GitArgBranch: initialContext = contextTree.Branches case appTypes.GitArgLog: initialContext = contextTree.LocalCommits case appTypes.GitArgStash: initialContext = contextTree.Stash default: panic("unhandled git arg") } } return initialContext } func (gui *Gui) Contexts() *context.ContextTree { return gui.State.Contexts } // for now the split view will always be on // NewGui builds a new gui handler func NewGui( cmn *common.Common, config config.AppConfigurer, gitVersion *git_commands.GitVersion, updater *updates.Updater, showRecentRepos bool, initialDir string, test integrationTypes.IntegrationTest, ) (*Gui, error) { gui := &Gui{ Common: cmn, gitVersion: gitVersion, Config: config, Updater: updater, statusManager: status.NewStatusManager(), viewBufferManagerMap: map[string]*tasks.ViewBufferManager{}, viewPtmxMap: map[string]*os.File{}, showRecentRepos: showRecentRepos, RepoPathStack: &utils.StringStack{}, RepoStateMap: map[Repo]*GuiRepoState{}, GuiLog: []string{}, // initializing this to true for the time being; it will be reset to the // real value after loading the user config: ShowExtrasWindow: true, Mutexes: types.Mutexes{ RefreshingFilesMutex: &deadlock.Mutex{}, RefreshingBranchesMutex: &deadlock.Mutex{}, RefreshingStatusMutex: &deadlock.Mutex{}, LocalCommitsMutex: &deadlock.Mutex{}, SubCommitsMutex: &deadlock.Mutex{}, AuthorsMutex: &deadlock.Mutex{}, SubprocessMutex: &deadlock.Mutex{}, PopupMutex: &deadlock.Mutex{}, PtyMutex: &deadlock.Mutex{}, }, InitialDir: initialDir, afterLayoutFuncs: make(chan func() error, 1000), itemOperations: make(map[string]types.ItemOperation), itemOperationsMutex: &deadlock.Mutex{}, } gui.PopupHandler = popup.NewPopupHandler( cmn, func(ctx goContext.Context, opts types.CreatePopupPanelOpts) { gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) }, func() error { return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }, func() { gui.State.ContextMgr.Pop() }, func() types.Context { return gui.State.ContextMgr.Current() }, gui.createMenu, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) }, func(message string, f func() error) error { return gui.helpers.AppStatus.WithWaitingStatusSync(message, f) }, func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) }, func() string { return gui.Views.Confirmation.TextArea.GetContent() }, func() bool { return gui.c.InDemo() }, ) guiCommon := &guiCommon{gui: gui, IPopupHandler: gui.PopupHandler} helperCommon := &helpers.HelperCommon{IGuiCommon: guiCommon, Common: cmn, IGetContexts: gui} credentialsHelper := helpers.NewCredentialsHelper(helperCommon) guiIO := oscommands.NewGuiIO( cmn.Log, gui.LogCommand, gui.getCmdWriter, credentialsHelper.PromptUserForCredential, ) osCommand := oscommands.NewOSCommand(cmn, config, oscommands.GetPlatform(), guiIO) gui.os = osCommand // storing this stuff on the gui for now to ease refactoring // TODO: reset these controllers upon changing repos due to state changing gui.c = helperCommon gui.BackgroundRoutineMgr = &BackgroundRoutineMgr{gui: gui} gui.stateAccessor = &StateAccessor{gui: gui} return gui, nil } var RuneReplacements = map[rune]string{ // for the commit graph graph.MergeSymbol: "M", graph.CommitSymbol: "o", } func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest) (*gocui.Gui, error) { runInSandbox := os.Getenv(components.SANDBOX_ENV_VAR) == "true" playRecording := test != nil && !runInSandbox width, height := 0, 0 if test != nil { if test.RequiresHeadless() { if runInSandbox { panic("Test requires headless, can't run in sandbox") } headless = true } width, height = test.HeadlessDimensions() } g, err := gocui.NewGui(gocui.NewGuiOpts{ OutputMode: gocui.OutputTrue, SupportOverlaps: OverlappingEdges, PlayRecording: playRecording, Headless: headless, RuneReplacements: RuneReplacements, Width: width, Height: height, }) if err != nil { return nil, err } return g, nil } func (gui *Gui) viewTabMap() map[string][]context.TabView { result := map[string][]context.TabView{ "branches": { { Tab: gui.c.Tr.LocalBranchesTitle, ViewName: "localBranches", }, { Tab: gui.c.Tr.RemotesTitle, ViewName: "remotes", }, { Tab: gui.c.Tr.TagsTitle, ViewName: "tags", }, }, "commits": { { Tab: gui.c.Tr.CommitsTitle, ViewName: "commits", }, { Tab: gui.c.Tr.ReflogCommitsTitle, ViewName: "reflogCommits", }, }, "files": { { Tab: gui.c.Tr.FilesTitle, ViewName: "files", }, context.TabView{ Tab: gui.c.Tr.WorktreesTitle, ViewName: "worktrees", }, { Tab: gui.c.Tr.SubmodulesTitle, ViewName: "submodules", }, }, } return result } // Run: setup the gui with keybindings and start the mainloop func (gui *Gui) Run(startArgs appTypes.StartArgs) error { g, err := gui.initGocui(Headless(), startArgs.IntegrationTest) if err != nil { return err } defer gui.checkForDeprecatedEditConfigs() gui.g = g defer gui.g.Close() g.ErrorHandler = gui.PopupHandler.ErrorHandler // if the deadlock package wants to report a deadlock, we first need to // close the gui so that we can actually read what it prints. deadlock.Opts.LogBuf = utils.NewOnceWriter(os.Stderr, func() { gui.g.Close() }) // disable deadlock reporting if we're not running in debug mode, or if // we're debugging an integration test. In this latter case, stopping at // breakpoints and stepping through code can easily take more than 30s. deadlock.Opts.Disable = !gui.Debug || os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) != "" gui.g.OnSearchEscape = func() error { gui.helpers.Search.Cancel(); return nil } gui.g.SetManager(gocui.ManagerFunc(gui.layout)) if err := gui.createAllViews(); err != nil { return err } // onNewRepo must be called after g.SetManager because SetManager deletes keybindings if err := gui.onNewRepo(startArgs, context.NO_CONTEXT); err != nil { return err } gui.waitForIntro.Add(1) gui.BackgroundRoutineMgr.startBackgroundRoutines() gui.c.Log.Info("starting main loop") // setting here so we can use it in layout.go gui.integrationTest = startArgs.IntegrationTest return gui.g.MainLoop() } func (gui *Gui) RunAndHandleError(startArgs appTypes.StartArgs) error { gui.stopChan = make(chan struct{}) return utils.SafeWithError(func() error { if err := gui.Run(startArgs); err != nil { for _, manager := range gui.viewBufferManagerMap { manager.Close() } close(gui.stopChan) switch err { case gocui.ErrQuit: if gui.c.State().GetRetainOriginalDir() { if err := gui.helpers.RecordDirectory.RecordDirectory(gui.InitialDir); err != nil { return err } } else { if err := gui.helpers.RecordDirectory.RecordCurrentDirectory(); err != nil { return err } } return nil default: return err } } return nil }) } func (gui *Gui) checkForDeprecatedEditConfigs() { osConfig := &gui.UserConfig().OS deprecatedConfigs := []struct { config string oldName string newName string }{ {osConfig.EditCommand, "EditCommand", "Edit"}, {osConfig.EditCommandTemplate, "EditCommandTemplate", "Edit,EditAtLine"}, {osConfig.OpenCommand, "OpenCommand", "Open"}, {osConfig.OpenLinkCommand, "OpenLinkCommand", "OpenLink"}, } deprecatedConfigStrings := []string{} for _, dc := range deprecatedConfigs { if dc.config != "" { deprecatedConfigStrings = append(deprecatedConfigStrings, fmt.Sprintf(" OS.%s -> OS.%s", dc.oldName, dc.newName)) } } if len(deprecatedConfigStrings) != 0 { warningMessage := utils.ResolvePlaceholderString( gui.c.Tr.DeprecatedEditConfigWarning, map[string]string{ "configs": strings.Join(deprecatedConfigStrings, "\n"), }, ) os.Stdout.Write([]byte(warningMessage)) } } // returns whether command exited without error or not func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess oscommands.ICmdObj) error { _, err := gui.runSubprocessWithSuspense(subprocess) if err != nil { return err } if err := gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil { return err } return nil } // returns whether command exited without error or not func (gui *Gui) runSubprocessWithSuspense(subprocess oscommands.ICmdObj) (bool, error) { gui.Mutexes.SubprocessMutex.Lock() defer gui.Mutexes.SubprocessMutex.Unlock() if err := gui.g.Suspend(); err != nil { return false, err } gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(true) defer gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(false) cmdErr := gui.runSubprocess(subprocess) if err := gui.g.Resume(); err != nil { return false, err } if cmdErr != nil { return false, cmdErr } return true, nil } func (gui *Gui) runSubprocess(cmdObj oscommands.ICmdObj) error { //nolint:unparam gui.LogCommand(cmdObj.ToString(), true) subprocess := cmdObj.GetCmd() subprocess.Stdout = os.Stdout subprocess.Stderr = os.Stderr subprocess.Stdin = os.Stdin fmt.Fprintf(os.Stdout, "\n%s\n\n", style.FgBlue.Sprint("+ "+strings.Join(subprocess.Args, " "))) err := subprocess.Run() subprocess.Stdout = io.Discard subprocess.Stderr = io.Discard subprocess.Stdin = nil if gui.integrationTest == nil && (gui.Config.GetUserConfig().PromptToReturnFromSubprocess || err != nil) { fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint(gui.Tr.PressEnterToReturn)) // scan to buffer to prevent run unintentional operations when TUI resumes. var buffer string _, _ = fmt.Scanln(&buffer) // wait for enter press } return err } func (gui *Gui) loadNewRepo() error { if err := gui.updateRecentRepoList(); err != nil { return err } if err := gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil { return err } if err := gui.os.UpdateWindowTitle(); err != nil { return err } return nil } func (gui *Gui) showIntroPopupMessage() { gui.waitForIntro.Add(1) gui.c.OnUIThread(func() error { onConfirm := func() error { gui.c.GetAppState().StartupPopupVersion = StartupPopupVersion err := gui.c.SaveAppState() gui.waitForIntro.Done() return err } introMessage := utils.ResolvePlaceholderString( gui.c.Tr.IntroPopupMessage, map[string]string{ "confirmationKey": gui.c.UserConfig().Keybinding.Universal.Confirm, }, ) gui.c.Confirm(types.ConfirmOpts{ Title: "", Prompt: introMessage, HandleConfirm: onConfirm, HandleClose: onConfirm, }) return nil }) } func (gui *Gui) showBreakingChangesMessage() { _, err := types.ParseVersionNumber(gui.Config.GetVersion()) if err != nil { // We don't have a parseable version, so we'll assume it's a developer // build, or a build from HEAD with a version such as 0.40.0-g1234567; // in these cases we don't show release notes. return } last := &types.VersionNumber{} lastVersionStr := gui.c.GetAppState().LastVersion // If there's no saved last version, we show all release notes. This is for // people upgrading from a version before we started to save lastVersion. // First time new users won't see the release notes because we show them the // intro popup instead. if lastVersionStr != "" { last, err = types.ParseVersionNumber(lastVersionStr) if err != nil { // The last version was a developer build, so don't show release // notes in this case either. return } } // Now collect all release notes texts for versions newer than lastVersion. // We don't need to bother checking the current version here, because we // can't possibly have texts for versions newer than current. type versionAndText struct { version *types.VersionNumber text string } texts := []versionAndText{} for versionStr, text := range gui.Tr.BreakingChangesByVersion { v, err := types.ParseVersionNumber(versionStr) if err != nil { // Ignore bogus entries in the BreakingChanges map continue } if last.IsOlderThan(v) { texts = append(texts, versionAndText{version: v, text: text}) } } if len(texts) > 0 { sort.Slice(texts, func(i, j int) bool { return texts[i].version.IsOlderThan(texts[j].version) }) message := strings.Join(lo.Map(texts, func(t versionAndText, _ int) string { return t.text }), "\n") gui.waitForIntro.Add(1) gui.c.OnUIThread(func() error { onConfirm := func() error { gui.waitForIntro.Done() return nil } gui.c.Confirm(types.ConfirmOpts{ Title: gui.Tr.BreakingChangesTitle, Prompt: gui.Tr.BreakingChangesMessage + "\n\n" + message, HandleConfirm: onConfirm, HandleClose: onConfirm, }) return nil }) } } // setColorScheme sets the color scheme for the app based on the user config func (gui *Gui) setColorScheme() { userConfig := gui.UserConfig() theme.UpdateTheme(userConfig.Gui.Theme) gui.g.FgColor = theme.InactiveBorderColor gui.g.SelFgColor = theme.ActiveBorderColor gui.g.FrameColor = theme.InactiveBorderColor gui.g.SelFrameColor = theme.ActiveBorderColor } func (gui *Gui) onUIThread(f func() error) { gui.g.Update(func(*gocui.Gui) error { return f() }) } func (gui *Gui) onWorker(f func(gocui.Task) error) { gui.g.OnWorker(f) } func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions { return gui.helpers.WindowArrangement.GetWindowDimensions(informationStr, appStatus) } func (gui *Gui) afterLayout(f func() error) { select { case gui.afterLayoutFuncs <- f: default: // hopefully this never happens gui.c.Log.Error("afterLayoutFuncs channel is full, skipping function") } } lazygit-0.50.0+ds1/pkg/gui/gui_common.go000066400000000000000000000111621500612110400177610ustar00rootroot00000000000000package gui import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/tasks" ) // hacking this by including the gui struct for now until we split more things out type guiCommon struct { gui *Gui types.IPopupHandler } var _ types.IGuiCommon = &guiCommon{} func (self *guiCommon) LogAction(msg string) { self.gui.LogAction(msg) } func (self *guiCommon) LogCommand(cmdStr string, isCommandLine bool) { self.gui.LogCommand(cmdStr, isCommandLine) } func (self *guiCommon) Refresh(opts types.RefreshOptions) error { return self.gui.helpers.Refresh.Refresh(opts) } func (self *guiCommon) PostRefreshUpdate(context types.Context) { self.gui.postRefreshUpdate(context) } func (self *guiCommon) RunSubprocessAndRefresh(cmdObj oscommands.ICmdObj) error { return self.gui.runSubprocessWithSuspenseAndRefresh(cmdObj) } func (self *guiCommon) RunSubprocess(cmdObj oscommands.ICmdObj) (bool, error) { return self.gui.runSubprocessWithSuspense(cmdObj) } func (self *guiCommon) Context() types.IContextMgr { return self.gui.State.ContextMgr } func (self *guiCommon) ContextForKey(key types.ContextKey) types.Context { return self.gui.State.ContextMgr.ContextForKey(key) } func (self *guiCommon) GetAppState() *config.AppState { return self.gui.Config.GetAppState() } func (self *guiCommon) SaveAppState() error { return self.gui.Config.SaveAppState() } func (self *guiCommon) SaveAppStateAndLogError() { if err := self.gui.Config.SaveAppState(); err != nil { self.gui.Log.Errorf("error when saving app state: %v", err) } } func (self *guiCommon) GetConfig() config.AppConfigurer { return self.gui.Config } func (self *guiCommon) ResetViewOrigin(view *gocui.View) { self.gui.resetViewOrigin(view) } func (self *guiCommon) SetViewContent(view *gocui.View, content string) { self.gui.setViewContent(view, content) } func (self *guiCommon) Render() { self.gui.render() } func (self *guiCommon) Views() types.Views { return self.gui.Views } func (self *guiCommon) Git() *commands.GitCommand { return self.gui.git } func (self *guiCommon) OS() *oscommands.OSCommand { return self.gui.os } func (self *guiCommon) Modes() *types.Modes { return self.gui.State.Modes } func (self *guiCommon) Model() *types.Model { return self.gui.State.Model } func (self *guiCommon) Mutexes() types.Mutexes { return self.gui.Mutexes } func (self *guiCommon) GocuiGui() *gocui.Gui { return self.gui.g } func (self *guiCommon) OnUIThread(f func() error) { self.gui.onUIThread(f) } func (self *guiCommon) OnWorker(f func(gocui.Task) error) { self.gui.onWorker(f) } func (self *guiCommon) RenderToMainViews(opts types.RefreshMainOpts) { self.gui.refreshMainViews(opts) } func (self *guiCommon) MainViewPairs() types.MainViewPairs { return types.MainViewPairs{ Normal: self.gui.normalMainContextPair(), Staging: self.gui.stagingMainContextPair(), PatchBuilding: self.gui.patchBuildingMainContextPair(), MergeConflicts: self.gui.mergingMainContextPair(), } } func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager { return self.gui.getViewBufferManagerForView(view) } func (self *guiCommon) State() types.IStateAccessor { return self.gui.stateAccessor } func (self *guiCommon) KeybindingsOpts() types.KeybindingsOpts { return self.gui.keybindingOpts() } func (self *guiCommon) CallKeybindingHandler(binding *types.Binding) error { return self.gui.callKeybindingHandler(binding) } func (self *guiCommon) ResetKeybindings() error { return self.gui.resetKeybindings() } func (self *guiCommon) IsAnyModeActive() bool { return self.gui.helpers.Mode.IsAnyModeActive() } func (self *guiCommon) GetInitialKeybindingsWithCustomCommands() ([]*types.Binding, []*gocui.ViewMouseBinding) { return self.gui.GetInitialKeybindingsWithCustomCommands() } func (self *guiCommon) AfterLayout(f func() error) { self.gui.afterLayout(f) } func (self *guiCommon) RunningIntegrationTest() bool { return self.gui.integrationTest != nil } func (self *guiCommon) InDemo() bool { return self.gui.integrationTest != nil && self.gui.integrationTest.IsDemo() } func (self *guiCommon) WithInlineStatus(item types.HasUrn, operation types.ItemOperation, contextKey types.ContextKey, f func(gocui.Task) error) error { self.gui.helpers.InlineStatus.WithInlineStatus(helpers.InlineStatusOpts{Item: item, Operation: operation, ContextKey: contextKey}, f) return nil } lazygit-0.50.0+ds1/pkg/gui/gui_driver.go000066400000000000000000000077361500612110400200000ustar00rootroot00000000000000package gui import ( "fmt" "os" "strings" "time" "github.com/gdamore/tcell/v2" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) // this gives our integration test a way of interacting with the gui for sending keypresses // and reading state. type GuiDriver struct { gui *Gui isIdleChan chan struct{} toastChan chan string headless bool } var _ integrationTypes.GuiDriver = &GuiDriver{} func (self *GuiDriver) PressKey(keyStr string) { self.CheckAllToastsAcknowledged() key := keybindings.GetKey(keyStr) var r rune var tcellKey tcell.Key switch v := key.(type) { case rune: r = v tcellKey = tcell.KeyRune case gocui.Key: tcellKey = tcell.Key(v) } self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper( tcell.NewEventKey(tcellKey, r, tcell.ModNone), 0, ) self.waitTillIdle() } func (self *GuiDriver) Click(x, y int) { self.CheckAllToastsAcknowledged() self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), 0, ) self.waitTillIdle() self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), 0, ) self.waitTillIdle() } // wait until lazygit is idle (i.e. all processing is done) before continuing func (self *GuiDriver) waitTillIdle() { <-self.isIdleChan } func (self *GuiDriver) CheckAllToastsAcknowledged() { if t := self.NextToast(); t != nil { self.Fail("Toast not acknowledged: " + *t) } } func (self *GuiDriver) Keys() config.KeybindingConfig { return self.gui.Config.GetUserConfig().Keybinding } func (self *GuiDriver) CurrentContext() types.Context { return self.gui.c.Context().Current() } func (self *GuiDriver) ContextForView(viewName string) types.Context { context, ok := self.gui.helpers.View.ContextForView(viewName) if !ok { return nil } return context } func (self *GuiDriver) Fail(message string) { currentView := self.gui.g.CurrentView() // Check for unacknowledged toast: it may give us a hint as to why the test failed toastMessage := "" if t := self.NextToast(); t != nil { toastMessage = fmt.Sprintf("Unacknowledged toast message: %s\n", *t) } fullMessage := fmt.Sprintf( "%s\nFinal Lazygit state:\n%s\nUpon failure, focused view was '%s'.\n%sLog:\n%s", message, self.gui.g.Snapshot(), currentView.Name(), toastMessage, strings.Join(self.gui.GuiLog, "\n"), ) self.gui.g.Close() // need to give the gui time to close time.Sleep(time.Millisecond * 100) _, err := fmt.Fprintln(os.Stderr, fullMessage) if err != nil { panic("Test failed. Failed writing to stderr") } panic("Test failed") } // logs to the normal place that you log to i.e. viewable with `lazygit --logs` func (self *GuiDriver) Log(message string) { self.gui.c.Log.Warn(message) } // logs in the actual UI (in the commands panel) func (self *GuiDriver) LogUI(message string) { self.gui.c.LogAction(message) } func (self *GuiDriver) CheckedOutRef() *models.Branch { return self.gui.helpers.Refs.GetCheckedOutRef() } func (self *GuiDriver) MainView() *gocui.View { return self.gui.mainView() } func (self *GuiDriver) SecondaryView() *gocui.View { return self.gui.secondaryView() } func (self *GuiDriver) View(viewName string) *gocui.View { view, err := self.gui.g.View(viewName) if err != nil { panic(err) } return view } func (self *GuiDriver) SetCaption(caption string) { self.gui.setCaption(caption) self.waitTillIdle() } func (self *GuiDriver) SetCaptionPrefix(prefix string) { self.gui.setCaptionPrefix(prefix) self.waitTillIdle() } func (self *GuiDriver) NextToast() *string { select { case t := <-self.toastChan: return &t default: return nil } } func (self *GuiDriver) Headless() bool { return self.headless } lazygit-0.50.0+ds1/pkg/gui/information_panel.go000066400000000000000000000020001500612110400213200ustar00rootroot00000000000000package gui import ( "fmt" "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/utils" ) func (gui *Gui) informationStr() string { if activeMode, ok := gui.helpers.Mode.GetActiveMode(); ok { return activeMode.Description() } if gui.g.Mouse { donate := style.FgMagenta.Sprint(style.PrintHyperlink(gui.c.Tr.Donate, constants.Links.Donate)) askQuestion := style.FgYellow.Sprint(style.PrintHyperlink(gui.c.Tr.AskQuestion, constants.Links.Discussions)) return fmt.Sprintf("%s %s %s", donate, askQuestion, gui.Config.GetVersion()) } else { return gui.Config.GetVersion() } } func (gui *Gui) handleInfoClick() error { if !gui.g.Mouse { return nil } view := gui.Views.Information cx, _ := view.Cursor() width := view.Width() if activeMode, ok := gui.helpers.Mode.GetActiveMode(); ok { if width-cx > utils.StringWidth(gui.c.Tr.ResetInParentheses) { return nil } return activeMode.Reset() } return nil } lazygit-0.50.0+ds1/pkg/gui/keybindings.go000066400000000000000000000401351500612110400201350ustar00rootroot00000000000000package gui import ( "errors" "log" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" ) func (gui *Gui) noPopupPanel(f func() error) func() error { return func() error { if gui.helpers.Confirmation.IsPopupPanelFocused() { return nil } return f() } } func (gui *Gui) outsideFilterMode(f func() error) func() error { return func() error { if !gui.validateNotInFilterMode() { return nil } return f() } } func (gui *Gui) validateNotInFilterMode() bool { if gui.State.Modes.Filtering.Active() { gui.c.Confirm(types.ConfirmOpts{ Title: gui.c.Tr.MustExitFilterModeTitle, Prompt: gui.c.Tr.MustExitFilterModePrompt, HandleConfirm: gui.helpers.Mode.ExitFilterMode, }) return false } return true } // only to be called from the cheatsheet generate script. This mutates the Gui struct. func (self *Gui) GetCheatsheetKeybindings() []*types.Binding { self.g = &gocui.Gui{} if err := self.createAllViews(); err != nil { panic(err) } // need to instantiate views self.helpers = helpers.NewStubHelpers() self.State = &GuiRepoState{} self.State.Contexts = self.contextTree() self.State.ContextMgr = NewContextMgr(self, self.State.Contexts) self.resetHelpersAndControllers() bindings, _ := self.GetInitialKeybindings() return bindings } func (self *Gui) keybindingOpts() types.KeybindingsOpts { config := self.c.UserConfig().Keybinding guards := types.KeybindingGuards{ OutsideFilterMode: self.outsideFilterMode, NoPopupPanel: self.noPopupPanel, } return types.KeybindingsOpts{ GetKey: keybindings.GetKey, Config: config, Guards: guards, } } // renaming receiver to 'self' to aid refactoring. Will probably end up moving all Gui handlers to this pattern eventually. func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBinding) { opts := self.c.KeybindingsOpts() bindings := []*types.Binding{ { ViewName: "", Key: opts.GetKey(opts.Config.Universal.OpenRecentRepos), Handler: opts.Guards.NoPopupPanel(self.helpers.Repos.CreateRecentReposMenu), Description: self.c.Tr.SwitchRepo, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.ScrollUpMain), Handler: self.scrollUpMain, Alternative: "fn+up/shift+k", Description: self.c.Tr.ScrollUpMainWindow, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.ScrollDownMain), Handler: self.scrollDownMain, Alternative: "fn+down/shift+j", Description: self.c.Tr.ScrollDownMainWindow, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.ScrollUpMainAlt1), Modifier: gocui.ModNone, Handler: self.scrollUpMain, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.ScrollDownMainAlt1), Modifier: gocui.ModNone, Handler: self.scrollDownMain, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.ScrollUpMainAlt2), Modifier: gocui.ModNone, Handler: self.scrollUpMain, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.ScrollDownMainAlt2), Modifier: gocui.ModNone, Handler: self.scrollDownMain, }, { ViewName: "files", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopyPathToClipboard, }, { ViewName: "localBranches", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopyBranchNameToClipboard, }, { ViewName: "remoteBranches", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopyBranchNameToClipboard, }, { ViewName: "tags", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopyTagToClipboard, }, { ViewName: "commits", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemCommitHashToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopyCommitHashToClipboard, }, { ViewName: "commits", Key: opts.GetKey(opts.Config.Commits.ResetCherryPick), Handler: self.helpers.CherryPick.Reset, Description: self.c.Tr.ResetCherryPick, }, { ViewName: "reflogCommits", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopyCommitHashToClipboard, }, { ViewName: "subCommits", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemCommitHashToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopyCommitHashToClipboard, }, { ViewName: "information", Key: gocui.MouseLeft, Modifier: gocui.ModNone, Handler: self.handleInfoClick, }, { ViewName: "commitFiles", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopyPathToClipboard, }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.ExtrasMenu), Handler: opts.Guards.NoPopupPanel(self.handleCreateExtrasMenuPanel), Description: self.c.Tr.OpenCommandLogMenu, Tooltip: self.c.Tr.OpenCommandLogMenuTooltip, OpensMenu: true, }, { ViewName: "main", Key: gocui.MouseWheelDown, Handler: self.scrollDownMain, Description: self.c.Tr.ScrollDown, Alternative: "fn+up", }, { ViewName: "main", Key: gocui.MouseWheelUp, Handler: self.scrollUpMain, Description: self.c.Tr.ScrollUp, Alternative: "fn+down", }, { ViewName: "secondary", Key: gocui.MouseWheelDown, Modifier: gocui.ModNone, Handler: self.scrollDownSecondary, }, { ViewName: "secondary", Key: gocui.MouseWheelUp, Modifier: gocui.ModNone, Handler: self.scrollUpSecondary, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Modifier: gocui.ModNone, Handler: self.scrollUpConfirmationPanel, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.NextItem), Modifier: gocui.ModNone, Handler: self.scrollDownConfirmationPanel, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Modifier: gocui.ModNone, Handler: self.scrollUpConfirmationPanel, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Modifier: gocui.ModNone, Handler: self.scrollDownConfirmationPanel, }, { ViewName: "confirmation", Key: gocui.MouseWheelUp, Handler: self.scrollUpConfirmationPanel, }, { ViewName: "confirmation", Key: gocui.MouseWheelDown, Handler: self.scrollDownConfirmationPanel, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.NextPage), Modifier: gocui.ModNone, Handler: self.pageDownConfirmationPanel, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.PrevPage), Modifier: gocui.ModNone, Handler: self.pageUpConfirmationPanel, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Modifier: gocui.ModNone, Handler: self.goToConfirmationPanelTop, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), Modifier: gocui.ModNone, Handler: self.goToConfirmationPanelTop, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Modifier: gocui.ModNone, Handler: self.goToConfirmationPanelBottom, }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), Modifier: gocui.ModNone, Handler: self.goToConfirmationPanelBottom, }, { ViewName: "submodules", Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason, Description: self.c.Tr.CopySubmoduleNameToClipboard, }, { ViewName: "extras", Key: gocui.MouseWheelUp, Handler: self.scrollUpExtra, }, { ViewName: "extras", Key: gocui.MouseWheelDown, Handler: self.scrollDownExtra, }, { ViewName: "extras", Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Modifier: gocui.ModNone, Handler: self.scrollUpExtra, }, { ViewName: "extras", Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Modifier: gocui.ModNone, Handler: self.scrollUpExtra, }, { ViewName: "extras", Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItem), Modifier: gocui.ModNone, Handler: self.scrollDownExtra, }, { ViewName: "extras", Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Modifier: gocui.ModNone, Handler: self.scrollDownExtra, }, { ViewName: "extras", Key: opts.GetKey(opts.Config.Universal.NextPage), Modifier: gocui.ModNone, Handler: self.pageDownExtrasPanel, }, { ViewName: "extras", Key: opts.GetKey(opts.Config.Universal.PrevPage), Modifier: gocui.ModNone, Handler: self.pageUpExtrasPanel, }, { ViewName: "extras", Key: opts.GetKey(opts.Config.Universal.GotoTop), Modifier: gocui.ModNone, Handler: self.goToExtrasPanelTop, }, { ViewName: "extras", Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), Modifier: gocui.ModNone, Handler: self.goToExtrasPanelTop, }, { ViewName: "extras", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Modifier: gocui.ModNone, Handler: self.goToExtrasPanelBottom, }, { ViewName: "extras", Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), Modifier: gocui.ModNone, Handler: self.goToExtrasPanelBottom, }, { ViewName: "extras", Tag: "navigation", Key: gocui.MouseLeft, Modifier: gocui.ModNone, Handler: self.handleFocusCommandLog, }, } mouseKeybindings := []*gocui.ViewMouseBinding{} for _, c := range self.State.Contexts.Flatten() { viewName := c.GetViewName() for _, binding := range c.GetKeybindings(opts) { // TODO: move all mouse keybindings into the mouse keybindings approach below binding.ViewName = viewName bindings = append(bindings, binding) } mouseKeybindings = append(mouseKeybindings, c.GetMouseKeybindings(opts)...) } bindings = append(bindings, []*types.Binding{ { ViewName: "", Key: opts.GetKey(opts.Config.Universal.NextTab), Handler: opts.Guards.NoPopupPanel(self.handleNextTab), Description: self.c.Tr.NextTab, Tag: "navigation", }, { ViewName: "", Key: opts.GetKey(opts.Config.Universal.PrevTab), Handler: opts.Guards.NoPopupPanel(self.handlePrevTab), Description: self.c.Tr.PrevTab, Tag: "navigation", }, }...) return bindings, mouseKeybindings } func (self *Gui) GetInitialKeybindingsWithCustomCommands() ([]*types.Binding, []*gocui.ViewMouseBinding) { // if the search or filter prompt is open, we only want the keybindings for // that context. It shouldn't be possible, for example, to open a menu while // the prompt is showing; you first need to confirm or cancel the search/filter. if currentContext := self.State.ContextMgr.Current(); currentContext.GetKey() == context.SEARCH_CONTEXT_KEY { bindings := currentContext.GetKeybindings(self.c.KeybindingsOpts()) viewName := currentContext.GetViewName() for _, binding := range bindings { binding.ViewName = viewName } return bindings, nil } bindings, mouseBindings := self.GetInitialKeybindings() customBindings, err := self.CustomCommandsClient.GetCustomCommandKeybindings() if err != nil { log.Fatal(err) } // prepending because we want to give our custom keybindings precedence over default keybindings bindings = append(customBindings, bindings...) return bindings, mouseBindings } func (gui *Gui) resetKeybindings() error { gui.g.DeleteAllKeybindings() bindings, mouseBindings := gui.GetInitialKeybindingsWithCustomCommands() for _, binding := range bindings { if err := gui.SetKeybinding(binding); err != nil { return err } } for _, binding := range mouseBindings { if err := gui.SetMouseKeybinding(binding); err != nil { return err } } for _, values := range gui.viewTabMap() { for _, value := range values { viewName := value.ViewName tabClickCallback := func(tabIndex int) error { return gui.onViewTabClick(gui.helpers.Window.WindowForView(viewName), tabIndex) } if err := gui.g.SetTabClickBinding(viewName, tabClickCallback); err != nil { return err } } } return nil } func (gui *Gui) wrappedHandler(f func() error) func(g *gocui.Gui, v *gocui.View) error { return func(g *gocui.Gui, v *gocui.View) error { return f() } } func (gui *Gui) SetKeybinding(binding *types.Binding) error { handler := func() error { return gui.callKeybindingHandler(binding) } // TODO: move all mouse-ey stuff into new mouse approach if gocui.IsMouseKey(binding.Key) { handler = func() error { // we ignore click events on views that aren't popup panels, when a popup panel is focused if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != binding.ViewName { return nil } return binding.Handler() } } return gui.g.SetKeybinding(binding.ViewName, binding.Key, binding.Modifier, gui.wrappedHandler(handler)) } // warning: mutates the binding func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error { baseHandler := binding.Handler newHandler := func(opts gocui.ViewMouseBindingOpts) error { // we ignore click events on views that aren't popup panels, when a popup panel is focused. // Unless both the current view and the clicked-on view are either commit message or commit // description, because we want to allow switching between those two views by clicking. isCommitMessageView := func(viewName string) bool { return viewName == "commitMessage" || viewName == "commitDescription" } if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != binding.ViewName && (!isCommitMessageView(gui.currentViewName()) || !isCommitMessageView(binding.ViewName)) { return nil } return baseHandler(opts) } binding.Handler = newHandler return gui.g.SetViewClickBinding(binding) } func (gui *Gui) callKeybindingHandler(binding *types.Binding) error { var disabledReason *types.DisabledReason if binding.GetDisabledReason != nil { disabledReason = binding.GetDisabledReason() } if disabledReason != nil { if disabledReason.ShowErrorInPanel { return errors.New(disabledReason.Text) } if len(disabledReason.Text) > 0 { gui.c.ErrorToast(gui.Tr.DisabledMenuItemPrefix + disabledReason.Text) } return nil } return binding.Handler() } lazygit-0.50.0+ds1/pkg/gui/keybindings/000077500000000000000000000000001500612110400176035ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/keybindings/keybindings.go000066400000000000000000000020261500612110400224400ustar00rootroot00000000000000package keybindings import ( "fmt" "log" "strings" "unicode/utf8" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/gui/types" ) func Label(name string) string { return LabelFromKey(GetKey(name)) } func LabelFromKey(key types.Key) string { keyInt := 0 switch key := key.(type) { case rune: keyInt = int(key) case gocui.Key: value, ok := config.LabelByKey[key] if ok { return value } keyInt = int(key) } return fmt.Sprintf("%c", keyInt) } func GetKey(key string) types.Key { runeCount := utf8.RuneCountInString(key) if key == "" { return nil } else if runeCount > 1 { binding, ok := config.KeyByLabel[strings.ToLower(key)] if !ok { log.Fatalf("Unrecognized key %s for keybinding. For permitted values see %s", strings.ToLower(key), constants.Links.Docs.CustomKeybindings) } else { return binding } } else if runeCount == 1 { return []rune(key)[0] } return nil } lazygit-0.50.0+ds1/pkg/gui/layout.go000066400000000000000000000163641500612110400171530ustar00rootroot00000000000000package gui import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) // layout is called for every screen re-render e.g. when the screen is resized func (gui *Gui) layout(g *gocui.Gui) error { if !gui.ViewsSetup { gui.printCommandLogHeader() if _, err := gui.g.SetCurrentView(gui.defaultSideContext().GetViewName()); err != nil { return err } } g.Highlight = true width, height := g.Size() informationStr := gui.informationStr() appStatus := gui.helpers.AppStatus.GetStatusString() viewDimensions := gui.getWindowDimensions(informationStr, appStatus) // reading more lines into main view buffers upon resize prevMainView := gui.Views.Main if prevMainView != nil { prevMainHeight := prevMainView.Height() newMainHeight := viewDimensions["main"].Y1 - viewDimensions["main"].Y0 + 1 heightDiff := newMainHeight - prevMainHeight if heightDiff > 0 { if manager := gui.getViewBufferManagerForView(gui.Views.Main); manager != nil { manager.ReadLines(heightDiff) } if manager := gui.getViewBufferManagerForView(gui.Views.Secondary); manager != nil { manager.ReadLines(heightDiff) } } } contextsToRerender := []types.Context{} // we assume that the view has already been created. setViewFromDimensions := func(context types.Context) (*gocui.View, error) { viewName := context.GetViewName() windowName := context.GetWindowName() dimensionsObj, ok := viewDimensions[windowName] view, err := g.View(viewName) if err != nil { return nil, err } if !ok { // view not specified in dimensions object: so create the view and hide it // making the view take up the whole space in the background in case it needs // to render content as soon as it appears, because lazyloaded content (via a pty task) // cares about the size of the view. _, err := g.SetView(viewName, 0, 0, width, height, 0) view.Visible = false return view, err } frameOffset := 1 if view.Frame { frameOffset = 0 } mustRerender := false newHeight := dimensionsObj.Y1 - dimensionsObj.Y0 + 2*frameOffset maxOriginY := context.TotalContentHeight() if !view.CanScrollPastBottom { maxOriginY -= newHeight - 1 } if oldOriginY := view.OriginY(); oldOriginY > maxOriginY { view.ScrollUp(oldOriginY - maxOriginY) // the view might not have scrolled actually (if it was at the limit // already), so we need to check if it did if oldOriginY != view.OriginY() && context.NeedsRerenderOnHeightChange() { mustRerender = true } } if context.NeedsRerenderOnWidthChange() == types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES { oldWidth := view.Width() newWidth := dimensionsObj.X1 - dimensionsObj.X0 + 1 if oldWidth != newWidth { mustRerender = true } } if context.NeedsRerenderOnHeightChange() { oldHeight := view.Height() newHeight := dimensionsObj.Y1 - dimensionsObj.Y0 + 1 if oldHeight != newHeight { mustRerender = true } } if mustRerender { contextsToRerender = append(contextsToRerender, context) } _, err = g.SetView( viewName, dimensionsObj.X0-frameOffset, dimensionsObj.Y0-frameOffset, dimensionsObj.X1+frameOffset, dimensionsObj.Y1+frameOffset, 0, ) view.Visible = true return view, err } for _, context := range gui.State.Contexts.Flatten() { if !context.HasControlledBounds() { continue } _, err := setViewFromDimensions(context) if err != nil && !gocui.IsUnknownView(err) { return err } } minimumHeight := 9 minimumWidth := 10 gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth gui.Views.Tooltip.Visible = gui.Views.Menu.Visible && gui.Views.Tooltip.Buffer() != "" for _, context := range gui.transientContexts() { view, err := gui.g.View(context.GetViewName()) if err != nil && !gocui.IsUnknownView(err) { return err } view.Visible = gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName() } if gui.PrevLayout.Information != informationStr { gui.c.SetViewContent(gui.Views.Information, informationStr) gui.PrevLayout.Information = informationStr } if !gui.ViewsSetup { if err := gui.onInitialViewsCreation(); err != nil { return err } gui.handleTestMode() gui.ViewsSetup = true } if !gui.State.ViewsSetup { if err := gui.onInitialViewsCreationForRepo(); err != nil { return err } gui.State.ViewsSetup = true } mainViewWidth, mainViewHeight := gui.Views.Main.Size() if mainViewWidth != gui.PrevLayout.MainWidth || mainViewHeight != gui.PrevLayout.MainHeight { gui.PrevLayout.MainWidth = mainViewWidth gui.PrevLayout.MainHeight = mainViewHeight if err := gui.onResize(); err != nil { return err } } for _, context := range contextsToRerender { context.HandleRender() } // here is a good place log some stuff // if you run `lazygit --logs` // this will let you see these branches as prettified json // gui.c.Log.Info(utils.AsJson(gui.State.Model.Branches[0:4])) gui.helpers.Confirmation.ResizeCurrentPopupPanels() gui.renderContextOptionsMap() outer: for { select { case f := <-gui.afterLayoutFuncs: if err := f(); err != nil { return err } default: break outer } } return nil } func (gui *Gui) prepareView(viewName string) (*gocui.View, error) { // arbitrarily giving the view enough size so that we don't get an error, but // it's expected that the view will be given the correct size before being shown return gui.g.SetView(viewName, 0, 0, 10, 10, 0) } func (gui *Gui) onInitialViewsCreationForRepo() error { if err := gui.onRepoViewReset(); err != nil { return err } // hide any popup views. This only applies when we've just switched repos for _, viewName := range gui.popupViewNames() { view, err := gui.g.View(viewName) if err == nil { view.Visible = false } } initialContext := gui.c.Context().Current() gui.c.Context().Activate(initialContext, types.OnFocusOpts{}) return gui.loadNewRepo() } func (gui *Gui) popupViewNames() []string { popups := lo.Filter(gui.State.Contexts.Flatten(), func(c types.Context, _ int) bool { return c.GetKind() == types.PERSISTENT_POPUP || c.GetKind() == types.TEMPORARY_POPUP }) return lo.Map(popups, func(c types.Context, _ int) string { return c.GetViewName() }) } func (gui *Gui) onRepoViewReset() error { // now we order the views (in order of bottom first) for _, view := range gui.orderedViews() { if _, err := gui.g.SetViewOnTop(view.Name()); err != nil { return err } } return nil } func (gui *Gui) onInitialViewsCreation() error { if !gui.c.UserConfig().DisableStartupPopups { storedPopupVersion := gui.c.GetAppState().StartupPopupVersion if storedPopupVersion < StartupPopupVersion { gui.showIntroPopupMessage() } else { gui.showBreakingChangesMessage() } } gui.c.GetAppState().LastVersion = gui.Config.GetVersion() gui.c.SaveAppStateAndLogError() if gui.showRecentRepos { if err := gui.helpers.Repos.CreateRecentReposMenu(); err != nil { return err } gui.showRecentRepos = false } gui.helpers.Update.CheckForUpdateInBackground() gui.waitForIntro.Done() return nil } func (gui *Gui) transientContexts() []types.Context { return lo.Filter(gui.State.Contexts.Flatten(), func(context types.Context, _ int) bool { return context.IsTransient() }) } lazygit-0.50.0+ds1/pkg/gui/main_panels.go000066400000000000000000000071441500612110400201200ustar00rootroot00000000000000package gui import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" ) func (gui *Gui) runTaskForView(view *gocui.View, task types.UpdateTask) error { switch v := task.(type) { case *types.RenderStringTask: return gui.newStringTask(view, v.Str) case *types.RenderStringWithoutScrollTask: return gui.newStringTaskWithoutScroll(view, v.Str) case *types.RenderStringWithScrollTask: return gui.newStringTaskWithScroll(view, v.Str, v.OriginX, v.OriginY) case *types.RunCommandTask: return gui.newCmdTask(view, v.Cmd, v.Prefix) case *types.RunPtyTask: return gui.newPtyTask(view, v.Cmd, v.Prefix) } return nil } func (gui *Gui) moveMainContextPairToTop(pair types.MainContextPair) { gui.moveMainContextToTop(pair.Main) if pair.Secondary != nil { gui.moveMainContextToTop(pair.Secondary) } } func (gui *Gui) moveMainContextToTop(context types.Context) { gui.helpers.Window.SetWindowContext(context) view := context.GetView() topView := gui.helpers.Window.TopViewInWindow(context.GetWindowName(), true) if topView != nil && topView != view { // We need to copy the content to avoid a flicker effect: If we're flicking // through files in the files panel, we use a different view to render the // files vs the directories, and if you select dir A, then file B, then dir // C, you'll briefly see dir A's contents again before the view is updated. // So here we're copying the content from the top window to avoid that // flicker effect. gui.g.CopyContent(topView, view) if err := gui.g.SetViewOnTopOf(view.Name(), topView.Name()); err != nil { gui.Log.Error(err) } } } func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Context) { view := context.GetView() if opts.Title != "" { view.Title = opts.Title } view.Subtitle = opts.SubTitle if err := gui.runTaskForView(view, opts.Task); err != nil { gui.c.Log.Error(err) } } func (gui *Gui) normalMainContextPair() types.MainContextPair { return types.NewMainContextPair( gui.State.Contexts.Normal, gui.State.Contexts.NormalSecondary, ) } func (gui *Gui) stagingMainContextPair() types.MainContextPair { return types.NewMainContextPair( gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, ) } func (gui *Gui) patchBuildingMainContextPair() types.MainContextPair { return types.NewMainContextPair( gui.State.Contexts.CustomPatchBuilder, gui.State.Contexts.CustomPatchBuilderSecondary, ) } func (gui *Gui) mergingMainContextPair() types.MainContextPair { return types.NewMainContextPair( gui.State.Contexts.MergeConflicts, nil, ) } func (gui *Gui) allMainContextPairs() []types.MainContextPair { return []types.MainContextPair{ gui.normalMainContextPair(), gui.stagingMainContextPair(), gui.patchBuildingMainContextPair(), gui.mergingMainContextPair(), } } func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { // need to reset scroll positions of all other main views for _, pair := range gui.allMainContextPairs() { if pair.Main != opts.Pair.Main { pair.Main.GetView().SetOrigin(0, 0) } if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary { pair.Secondary.GetView().SetOrigin(0, 0) } } if opts.Main != nil { gui.RefreshMainView(opts.Main, opts.Pair.Main) } if opts.Secondary != nil { gui.RefreshMainView(opts.Secondary, opts.Pair.Secondary) } else if opts.Pair.Secondary != nil { opts.Pair.Secondary.GetView().Clear() } gui.moveMainContextPairToTop(opts.Pair) gui.splitMainPanel(opts.Secondary != nil) } func (gui *Gui) splitMainPanel(splitMainPanel bool) { gui.State.SplitMainPanel = splitMainPanel } lazygit-0.50.0+ds1/pkg/gui/menu_panel.go000066400000000000000000000034611500612110400177530ustar00rootroot00000000000000package gui import ( "fmt" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" ) // note: items option is mutated by this function func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { if !opts.HideCancel { // this is mutative but I'm okay with that for now opts.Items = append(opts.Items, &types.MenuItem{ LabelColumns: []string{gui.c.Tr.Cancel}, OnPress: func() error { return nil }, }) } maxColumnSize := 1 for _, item := range opts.Items { if item.LabelColumns == nil { item.LabelColumns = []string{item.Label} } if item.OpensMenu { item.LabelColumns[0] = fmt.Sprintf("%s...", item.LabelColumns[0]) } maxColumnSize = max(maxColumnSize, len(item.LabelColumns)) } for _, item := range opts.Items { if len(item.LabelColumns) < maxColumnSize { // we require that each item has the same number of columns so we're padding out with blank strings // if this item has too few item.LabelColumns = append(item.LabelColumns, make([]string, maxColumnSize-len(item.LabelColumns))...) } } gui.State.Contexts.Menu.SetMenuItems(opts.Items, opts.ColumnAlignment) gui.State.Contexts.Menu.SetPrompt(opts.Prompt) gui.State.Contexts.Menu.SetSelection(0) gui.Views.Menu.Title = opts.Title gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor gui.Views.Tooltip.Wrap = true gui.Views.Tooltip.FgColor = theme.GocuiDefaultTextColor gui.Views.Tooltip.Visible = true // resetting keybindings so that the menu-specific keybindings are registered if err := gui.resetKeybindings(); err != nil { return err } gui.c.PostRefreshUpdate(gui.State.Contexts.Menu) // TODO: ensure that if we're opened a menu from within a menu that it renders correctly gui.c.Context().Push(gui.State.Contexts.Menu, types.OnFocusOpts{}) return nil } lazygit-0.50.0+ds1/pkg/gui/mergeconflicts/000077500000000000000000000000001500612110400203015ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/mergeconflicts/find_conflicts.go000066400000000000000000000052301500612110400236140ustar00rootroot00000000000000package mergeconflicts import ( "bufio" "bytes" "io" "os" "strings" "github.com/jesseduffield/lazygit/pkg/utils" ) // LineType tells us whether a given line is a start/middle/end marker of a conflict, // or if it's not a marker at all type LineType int const ( START LineType = iota ANCESTOR TARGET END NOT_A_MARKER ) func findConflicts(content string) []*mergeConflict { conflicts := make([]*mergeConflict, 0) if content == "" { return conflicts } var newConflict *mergeConflict for i, line := range utils.SplitLines(content) { switch determineLineType(line) { case START: newConflict = &mergeConflict{start: i, ancestor: -1} case ANCESTOR: if newConflict != nil { newConflict.ancestor = i } case TARGET: if newConflict != nil { newConflict.target = i } case END: if newConflict != nil { newConflict.end = i conflicts = append(conflicts, newConflict) } // reset value to avoid any possible silent mutations in further iterations newConflict = nil default: // line isn't a merge conflict marker so we just continue } } return conflicts } var ( CONFLICT_START = "<<<<<<< " CONFLICT_END = ">>>>>>> " CONFLICT_START_BYTES = []byte(CONFLICT_START) CONFLICT_END_BYTES = []byte(CONFLICT_END) ) func determineLineType(line string) LineType { // TODO: find out whether we ever actually get this prefix trimmedLine := strings.TrimPrefix(line, "++") switch { case strings.HasPrefix(trimmedLine, CONFLICT_START): return START case strings.HasPrefix(trimmedLine, "||||||| "): return ANCESTOR case trimmedLine == "=======": return TARGET case strings.HasPrefix(trimmedLine, CONFLICT_END): return END default: return NOT_A_MARKER } } // tells us whether a file actually has inline merge conflicts. We need to run this // because git will continue showing a status of 'UU' even after the conflicts have // been resolved in the user's editor func FileHasConflictMarkers(path string) (bool, error) { file, err := os.Open(path) if err != nil { return false, err } defer file.Close() return fileHasConflictMarkersAux(file), nil } // Efficiently scans through a file looking for merge conflict markers. Returns true if it does func fileHasConflictMarkersAux(file io.Reader) bool { scanner := bufio.NewScanner(file) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) for scanner.Scan() { line := scanner.Bytes() // only searching for start/end markers because the others are more ambiguous if bytes.HasPrefix(line, CONFLICT_START_BYTES) { return true } if bytes.HasPrefix(line, CONFLICT_END_BYTES) { return true } } return false } lazygit-0.50.0+ds1/pkg/gui/mergeconflicts/find_conflicts_test.go000066400000000000000000000030411500612110400246510ustar00rootroot00000000000000package mergeconflicts import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestDetermineLineType(t *testing.T) { type scenario struct { line string expected LineType } scenarios := []scenario{ { line: "", expected: NOT_A_MARKER, }, { line: "blah", expected: NOT_A_MARKER, }, { line: "<<<<<<< HEAD", expected: START, }, { line: "<<<<<<< HEAD:my_branch", expected: START, }, { line: "<<<<<<< MERGE_HEAD:my_branch", expected: START, }, { line: "<<<<<<< Updated upstream:my_branch", expected: START, }, { line: "<<<<<<< ours:my_branch", expected: START, }, { line: "=======", expected: TARGET, }, { line: ">>>>>>> blah", expected: END, }, { line: "||||||| adf33b9", expected: ANCESTOR, }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, determineLineType(s.line)) } } func TestFindConflictsAux(t *testing.T) { type scenario struct { content string expected bool } scenarios := []scenario{ { content: "", expected: false, }, { content: "blah", expected: false, }, { content: ">>>>>>> ", expected: true, }, { content: "<<<<<<< ", expected: true, }, { content: " <<<<<<< ", expected: false, }, { content: "a\nb\nc\n<<<<<<< ", expected: true, }, } for _, s := range scenarios { reader := strings.NewReader(s.content) assert.EqualValues(t, s.expected, fileHasConflictMarkersAux(reader)) } } lazygit-0.50.0+ds1/pkg/gui/mergeconflicts/merge_conflict.go000066400000000000000000000031461500612110400236140ustar00rootroot00000000000000package mergeconflicts // mergeConflict : A git conflict with a start, ancestor (if exists), target, and end corresponding to line // numbers in the file where the conflict markers appear. // If no ancestor is present (i.e. we're not using the diff3 algorithm), then // the `ancestor` field's value will be -1 type mergeConflict struct { start int ancestor int target int end int } func (c *mergeConflict) hasAncestor() bool { return c.ancestor >= 0 } func (c *mergeConflict) isMarkerLine(i int) bool { return i == c.start || i == c.ancestor || i == c.target || i == c.end } type Selection int const ( TOP Selection = iota MIDDLE BOTTOM ALL ) func (s Selection) isIndexToKeep(conflict *mergeConflict, i int) bool { // we're only handling one conflict at a time so any lines outside this // conflict we'll keep if i < conflict.start || conflict.end < i { return true } if conflict.isMarkerLine(i) { return false } return s.selected(conflict, i) } func (s Selection) bounds(c *mergeConflict) (int, int) { switch s { case TOP: if c.hasAncestor() { return c.start, c.ancestor } else { return c.start, c.target } case MIDDLE: return c.ancestor, c.target case BOTTOM: return c.target, c.end case ALL: return c.start, c.end } panic("unexpected selection for merge conflict") } func (s Selection) selected(c *mergeConflict, idx int) bool { start, end := s.bounds(c) return start < idx && idx < end } func availableSelections(c *mergeConflict) []Selection { if c.hasAncestor() { return []Selection{TOP, MIDDLE, BOTTOM} } else { return []Selection{TOP, BOTTOM} } } lazygit-0.50.0+ds1/pkg/gui/mergeconflicts/rendering.go000066400000000000000000000016161500612110400226110ustar00rootroot00000000000000package mergeconflicts import ( "bytes" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" ) func ColoredConflictFile(state *State) string { content := state.GetContent() if len(state.conflicts) == 0 { return content } conflict, remainingConflicts := shiftConflict(state.conflicts) var outputBuffer bytes.Buffer for i, line := range utils.SplitLines(content) { textStyle := theme.DefaultTextColor if conflict.isMarkerLine(i) { textStyle = style.FgRed } if i == conflict.end && len(remainingConflicts) > 0 { conflict, remainingConflicts = shiftConflict(remainingConflicts) } outputBuffer.WriteString(textStyle.Sprint(line) + "\n") } return outputBuffer.String() } func shiftConflict(conflicts []*mergeConflict) (*mergeConflict, []*mergeConflict) { return conflicts[0], conflicts[1:] } lazygit-0.50.0+ds1/pkg/gui/mergeconflicts/state.go000066400000000000000000000114361500612110400217550ustar00rootroot00000000000000package mergeconflicts import ( "strings" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // State represents the selection state of the merge conflict context. type State struct { // path of the file with the conflicts path string // This is a stack of the file content. It is used to undo changes. // The last item is the current file content. contents []string conflicts []*mergeConflict // this is the index of the above `conflicts` field which is currently selected conflictIndex int // this is the index of the selected conflict's available selections slice e.g. [TOP, MIDDLE, BOTTOM] // We use this to know which hunk of the conflict is selected. selectionIndex int } func NewState() *State { return &State{ conflictIndex: 0, selectionIndex: 0, conflicts: []*mergeConflict{}, contents: []string{}, } } func (s *State) setConflictIndex(index int) { if len(s.conflicts) == 0 { s.conflictIndex = 0 } else { s.conflictIndex = lo.Clamp(index, 0, len(s.conflicts)-1) } s.setSelectionIndex(s.selectionIndex) } func (s *State) setSelectionIndex(index int) { if selections := s.availableSelections(); len(selections) != 0 { s.selectionIndex = lo.Clamp(index, 0, len(selections)-1) } } func (s *State) SelectNextConflictHunk() { s.setSelectionIndex(s.selectionIndex + 1) } func (s *State) SelectPrevConflictHunk() { s.setSelectionIndex(s.selectionIndex - 1) } func (s *State) SelectNextConflict() { s.setConflictIndex(s.conflictIndex + 1) } func (s *State) SelectPrevConflict() { s.setConflictIndex(s.conflictIndex - 1) } func (s *State) currentConflict() *mergeConflict { if len(s.conflicts) == 0 { return nil } return s.conflicts[s.conflictIndex] } // this is for starting a new merge conflict session func (s *State) SetContent(content string, path string) { if content == s.GetContent() && path == s.path { return } s.path = path s.contents = []string{} s.PushContent(content) } // this is for when you've resolved a conflict. This allows you to undo to a previous // state func (s *State) PushContent(content string) { s.contents = append(s.contents, content) s.setConflicts(findConflicts(content)) } func (s *State) GetContent() string { if len(s.contents) == 0 { return "" } return s.contents[len(s.contents)-1] } func (s *State) GetPath() string { return s.path } func (s *State) Undo() bool { if len(s.contents) <= 1 { return false } s.contents = s.contents[:len(s.contents)-1] newContent := s.GetContent() // We could be storing the old conflicts and selected index on a stack too. s.setConflicts(findConflicts(newContent)) return true } func (s *State) setConflicts(conflicts []*mergeConflict) { s.conflicts = conflicts s.setConflictIndex(s.conflictIndex) } func (s *State) NoConflicts() bool { return len(s.conflicts) == 0 } func (s *State) Selection() Selection { if selections := s.availableSelections(); len(selections) > 0 { return selections[s.selectionIndex] } return TOP } func (s *State) availableSelections() []Selection { if conflict := s.currentConflict(); conflict != nil { return availableSelections(conflict) } return nil } func (s *State) AllConflictsResolved() bool { return len(s.conflicts) == 0 } func (s *State) Reset() { s.contents = []string{} s.path = "" } // we're not resetting selectedIndex here because the user typically would want // to pick either all top hunks or all bottom hunks so we retain that selection func (s *State) ResetConflictSelection() { s.conflictIndex = 0 } func (s *State) Active() bool { return s.path != "" } func (s *State) GetConflictMiddle() int { currentConflict := s.currentConflict() if currentConflict == nil { return 0 } return currentConflict.target } func (s *State) ContentAfterConflictResolve(selection Selection) (bool, string, error) { conflict := s.currentConflict() if conflict == nil { return false, "", nil } content := "" err := utils.ForEachLineInFile(s.path, func(line string, i int) { if selection.isIndexToKeep(conflict, i) { content += line } }) if err != nil { return false, "", err } return true, content, nil } func (s *State) GetSelectedLine() int { conflict := s.currentConflict() if conflict == nil { // TODO: see why this is 1 and not 0 return 1 } selection := s.Selection() startIndex, _ := selection.bounds(conflict) return startIndex + 1 } func (s *State) GetSelectedRange() (int, int) { conflict := s.currentConflict() if conflict == nil { return 0, 0 } selection := s.Selection() startIndex, endIndex := selection.bounds(conflict) return startIndex, endIndex } func (s *State) PlainRenderSelected() string { startIndex, endIndex := s.GetSelectedRange() content := s.GetContent() contentLines := utils.SplitLines(content) return strings.Join(contentLines[startIndex:endIndex+1], "\n") } lazygit-0.50.0+ds1/pkg/gui/mergeconflicts/state_test.go000066400000000000000000000031071500612110400230100ustar00rootroot00000000000000package mergeconflicts import ( "testing" "github.com/stretchr/testify/assert" ) func TestFindConflicts(t *testing.T) { type scenario struct { name string content string expected []*mergeConflict } scenarios := []scenario{ { name: "empty", content: "", expected: []*mergeConflict{}, }, { name: "various conflicts", content: `++<<<<<<< HEAD foo ++======= bar ++>>>>>>> branch <<<<<<< HEAD: foo/bar/baz.go foo bar ======= baz >>>>>>> branch ++<<<<<<< MERGE_HEAD foo ++======= bar ++>>>>>>> branch ++<<<<<<< Updated upstream foo ++======= bar ++>>>>>>> branch ++<<<<<<< ours foo ++======= bar ++>>>>>>> branch <<<<<<< Updated upstream: foo/bar/baz.go foo bar ======= baz >>>>>>> branch <<<<<<< HEAD foo ||||||| fffffff bar ======= baz >>>>>>> branch `, expected: []*mergeConflict{ { start: 0, ancestor: -1, target: 2, end: 4, }, { start: 6, ancestor: -1, target: 9, end: 11, }, { start: 13, ancestor: -1, target: 15, end: 17, }, { start: 19, ancestor: -1, target: 21, end: 23, }, { start: 25, ancestor: -1, target: 27, end: 29, }, { start: 31, ancestor: -1, target: 34, end: 36, }, { start: 38, ancestor: 40, target: 42, end: 44, }, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { assert.EqualValues(t, s.expected, findConflicts(s.content)) }) } } lazygit-0.50.0+ds1/pkg/gui/modes/000077500000000000000000000000001500612110400164045ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/modes/cherrypicking/000077500000000000000000000000001500612110400212455ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/modes/cherrypicking/cherry_picking.go000066400000000000000000000035201500612110400245740ustar00rootroot00000000000000package cherrypicking import ( "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/samber/lo" ) type CherryPicking struct { CherryPickedCommits []*models.Commit // we only allow cherry picking from one context at a time, so you can't copy a commit from // the local commits context and then also copy a commit in the reflog context ContextKey string // keep track of whether the currently copied commits have been pasted already. If so, we hide // the mode and the blue display of the commits, but we still allow pasting them again. DidPaste bool } func New() *CherryPicking { return &CherryPicking{ CherryPickedCommits: make([]*models.Commit, 0), ContextKey: "", } } func (self *CherryPicking) Active() bool { return self.CanPaste() && !self.DidPaste } func (self *CherryPicking) CanPaste() bool { return len(self.CherryPickedCommits) > 0 } func (self *CherryPicking) SelectedHashSet() *set.Set[string] { if self.DidPaste { return set.New[string]() } hashes := lo.Map(self.CherryPickedCommits, func(commit *models.Commit, _ int) string { return commit.Hash() }) return set.NewFromSlice(hashes) } func (self *CherryPicking) Add(selectedCommit *models.Commit, commitsList []*models.Commit) { commitSet := self.SelectedHashSet() commitSet.Add(selectedCommit.Hash()) self.update(commitSet, commitsList) } func (self *CherryPicking) Remove(selectedCommit *models.Commit, commitsList []*models.Commit) { commitSet := self.SelectedHashSet() commitSet.Remove(selectedCommit.Hash()) self.update(commitSet, commitsList) } func (self *CherryPicking) update(selectedHashSet *set.Set[string], commitsList []*models.Commit) { self.CherryPickedCommits = lo.Filter(commitsList, func(commit *models.Commit, _ int) bool { return selectedHashSet.Includes(commit.Hash()) }) } lazygit-0.50.0+ds1/pkg/gui/modes/diffing/000077500000000000000000000000001500612110400200125ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/modes/diffing/diffing.go000066400000000000000000000011501500612110400217440ustar00rootroot00000000000000package diffing // if ref is blank we're not diffing anything type Diffing struct { Ref string Reverse bool } func New() Diffing { return Diffing{} } func (self *Diffing) Active() bool { return self.Ref != "" } // GetFromAndReverseArgsForDiff tells us the from and reverse args to be used in a diff command. // If we're not in diff mode we'll end up with the equivalent of a `git show` i.e `git diff blah^..blah`. func (self *Diffing) GetFromAndReverseArgsForDiff(from string) (string, bool) { reverse := false if self.Active() { reverse = self.Reverse from = self.Ref } return from, reverse } lazygit-0.50.0+ds1/pkg/gui/modes/filtering/000077500000000000000000000000001500612110400203675ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/modes/filtering/filtering.go000066400000000000000000000016501500612110400227030ustar00rootroot00000000000000package filtering type Filtering struct { path string // the filename that gets passed to git log author string // the author that gets passed to git log selectedCommitHash string // the commit that was selected before we entered filtering mode } func New(path string, author string) Filtering { return Filtering{path: path, author: author} } func (m *Filtering) Active() bool { return m.path != "" || m.author != "" } func (m *Filtering) Reset() { m.path = "" m.author = "" } func (m *Filtering) SetPath(path string) { m.path = path } func (m *Filtering) GetPath() string { return m.path } func (m *Filtering) SetAuthor(author string) { m.author = author } func (m *Filtering) GetAuthor() string { return m.author } func (m *Filtering) SetSelectedCommitHash(hash string) { m.selectedCommitHash = hash } func (m *Filtering) GetSelectedCommitHash() string { return m.selectedCommitHash } lazygit-0.50.0+ds1/pkg/gui/modes/marked_base_commit/000077500000000000000000000000001500612110400222115ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/modes/marked_base_commit/marked_base_commit.go000066400000000000000000000007221500612110400263460ustar00rootroot00000000000000package marked_base_commit type MarkedBaseCommit struct { hash string // the hash of the commit used as a rebase base commit; empty string when unset } func New() MarkedBaseCommit { return MarkedBaseCommit{} } func (m *MarkedBaseCommit) Active() bool { return m.hash != "" } func (m *MarkedBaseCommit) Reset() { m.hash = "" } func (m *MarkedBaseCommit) SetHash(hash string) { m.hash = hash } func (m *MarkedBaseCommit) GetHash() string { return m.hash } lazygit-0.50.0+ds1/pkg/gui/options_map.go000066400000000000000000000106331500612110400201570ustar00rootroot00000000000000package gui import ( "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type OptionsMapMgr struct { c *helpers.HelperCommon } func (gui *Gui) renderContextOptionsMap() { // In demos, we render our own content to this view if gui.integrationTest != nil && gui.integrationTest.IsDemo() { return } mgr := OptionsMapMgr{c: gui.c} mgr.renderContextOptionsMap() } // Render the options available for the current context at the bottom of the screen // STYLE GUIDE: we use the default options fg color for most keybindings. We can // only use a different color if we're in a specific mode where the user is likely // to want to press that key. For example, when in cherry-picking mode, we // want to prominently show the keybinding for pasting commits. func (self *OptionsMapMgr) renderContextOptionsMap() { currentContext := self.c.Context().Current() currentContextBindings := currentContext.GetKeybindings(self.c.KeybindingsOpts()) globalBindings := self.c.Contexts().Global.GetKeybindings(self.c.KeybindingsOpts()) allBindings := append(currentContextBindings, globalBindings...) bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool { return binding.DisplayOnScreen && !binding.IsDisabled() }) optionsMap := lo.Map(bindingsToDisplay, func(binding *types.Binding, _ int) bindingInfo { displayStyle := theme.OptionsFgColor if binding.DisplayStyle != nil { displayStyle = *binding.DisplayStyle } description := binding.Description if binding.ShortDescription != "" { description = binding.ShortDescription } return bindingInfo{ key: keybindings.LabelFromKey(binding.Key), description: description, style: displayStyle, } }) // Mode-specific local keybindings if currentContext.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { if self.c.Modes().CherryPicking.Active() { optionsMap = utils.Prepend(optionsMap, bindingInfo{ key: keybindings.Label(self.c.KeybindingsOpts().Config.Commits.PasteCommits), description: self.c.Tr.PasteCommits, style: style.FgCyan, }) } if self.c.Model().BisectInfo.Started() { optionsMap = utils.Prepend(optionsMap, bindingInfo{ key: keybindings.Label(self.c.KeybindingsOpts().Config.Commits.ViewBisectOptions), description: self.c.Tr.ViewBisectOptions, style: style.FgGreen, }) } } // Mode-specific global keybindings if state := self.c.Model().WorkingTreeStateAtLastCommitRefresh; state.Any() { optionsMap = utils.Prepend(optionsMap, bindingInfo{ key: keybindings.Label(self.c.KeybindingsOpts().Config.Universal.CreateRebaseOptionsMenu), description: state.OptionsMapTitle(self.c.Tr), style: style.FgYellow, }) } if self.c.Git().Patch.PatchBuilder.Active() { optionsMap = utils.Prepend(optionsMap, bindingInfo{ key: keybindings.Label(self.c.KeybindingsOpts().Config.Universal.CreatePatchOptionsMenu), description: self.c.Tr.ViewPatchOptions, style: style.FgYellow, }) } self.renderOptions(self.formatBindingInfos(optionsMap)) } func (self *OptionsMapMgr) formatBindingInfos(bindingInfos []bindingInfo) string { width := self.c.Views().Options.InnerWidth() - 2 // -2 for some padding var builder strings.Builder ellipsis := "…" separator := " | " length := 0 for i, info := range bindingInfos { plainText := fmt.Sprintf("%s: %s", info.description, info.key) // Check if adding the next formatted string exceeds the available width textLen := utils.StringWidth(plainText) if i > 0 && length+len(separator)+textLen > width { builder.WriteString(theme.OptionsFgColor.Sprint(separator + ellipsis)) break } formatted := info.style.Sprintf(plainText) if i > 0 { builder.WriteString(theme.OptionsFgColor.Sprint(separator)) length += len(separator) } builder.WriteString(formatted) length += textLen } return builder.String() } func (self *OptionsMapMgr) renderOptions(options string) { self.c.SetViewContent(self.c.Views().Options, options) } type bindingInfo struct { key string description string style style.TextStyle } lazygit-0.50.0+ds1/pkg/gui/patch_exploring/000077500000000000000000000000001500612110400204635ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/patch_exploring/focus.go000066400000000000000000000033401500612110400221310ustar00rootroot00000000000000package patch_exploring func calculateOrigin(currentOrigin int, bufferHeight int, numLines int, firstLineIdx int, lastLineIdx int, selectedLineIdx int, mode selectMode) int { needToSeeIdx, wantToSeeIdx := getNeedAndWantLineIdx(firstLineIdx, lastLineIdx, selectedLineIdx, mode) return calculateNewOriginWithNeededAndWantedIdx(currentOrigin, bufferHeight, numLines, needToSeeIdx, wantToSeeIdx) } // we want to scroll our origin so that the index we need to see is in view // and the other index we want to see (e.g. the other side of a line range) // is as close to being in view as possible. func calculateNewOriginWithNeededAndWantedIdx(currentOrigin int, bufferHeight int, numLines int, needToSeeIdx int, wantToSeeIdx int) int { origin := currentOrigin if needToSeeIdx < currentOrigin || needToSeeIdx >= currentOrigin+bufferHeight { origin = max(min(needToSeeIdx-bufferHeight/2, numLines-bufferHeight), 0) } bottom := origin + bufferHeight if wantToSeeIdx < origin { requiredChange := origin - wantToSeeIdx allowedChange := bottom - needToSeeIdx return origin - min(requiredChange, allowedChange) } else if wantToSeeIdx >= bottom { requiredChange := wantToSeeIdx - bottom allowedChange := needToSeeIdx - origin return origin + min(requiredChange, allowedChange) } else { return origin } } func getNeedAndWantLineIdx(firstLineIdx int, lastLineIdx int, selectedLineIdx int, mode selectMode) (int, int) { switch mode { case LINE: return selectedLineIdx, selectedLineIdx case RANGE: if selectedLineIdx == firstLineIdx { return firstLineIdx, lastLineIdx } else { return lastLineIdx, firstLineIdx } case HUNK: return firstLineIdx, lastLineIdx default: // we should never land here panic("unknown mode") } } lazygit-0.50.0+ds1/pkg/gui/patch_exploring/focus_test.go000066400000000000000000000062641500612110400232000ustar00rootroot00000000000000package patch_exploring import ( "testing" "github.com/stretchr/testify/assert" ) func TestNewOrigin(t *testing.T) { type scenario struct { name string origin int bufferHeight int numLines int firstLineIdx int lastLineIdx int selectedLineIdx int selectMode selectMode expected int } scenarios := []scenario{ { name: "selection above scroll window, enough room to put it in the middle", origin: 250, bufferHeight: 100, numLines: 500, firstLineIdx: 210, lastLineIdx: 210, selectedLineIdx: 210, selectMode: LINE, expected: 160, }, { name: "selection above scroll window, not enough room to put it in the middle", origin: 50, bufferHeight: 100, numLines: 500, firstLineIdx: 10, lastLineIdx: 10, selectedLineIdx: 10, selectMode: LINE, expected: 0, }, { name: "selection below scroll window, enough room to put it in the middle", origin: 0, bufferHeight: 100, numLines: 500, firstLineIdx: 150, lastLineIdx: 150, selectedLineIdx: 150, selectMode: LINE, expected: 100, }, { name: "selection below scroll window, not enough room to put it in the middle", origin: 0, bufferHeight: 100, numLines: 200, firstLineIdx: 199, lastLineIdx: 199, selectedLineIdx: 199, selectMode: LINE, expected: 100, }, { name: "selection within scroll window", origin: 0, bufferHeight: 100, numLines: 500, firstLineIdx: 50, lastLineIdx: 50, selectedLineIdx: 50, selectMode: LINE, expected: 0, }, { name: "range ending below scroll window with selection at end of range", origin: 0, bufferHeight: 100, numLines: 500, firstLineIdx: 40, lastLineIdx: 150, selectedLineIdx: 150, selectMode: RANGE, expected: 50, }, { name: "range ending below scroll window with selection at beginning of range", origin: 0, bufferHeight: 100, numLines: 500, firstLineIdx: 40, lastLineIdx: 150, selectedLineIdx: 40, selectMode: RANGE, expected: 40, }, { name: "range starting above scroll window with selection at beginning of range", origin: 50, bufferHeight: 100, numLines: 500, firstLineIdx: 40, lastLineIdx: 150, selectedLineIdx: 40, selectMode: RANGE, expected: 40, }, { name: "hunk extending beyond both bounds of scroll window", origin: 50, bufferHeight: 100, numLines: 500, firstLineIdx: 40, lastLineIdx: 200, selectedLineIdx: 70, selectMode: HUNK, expected: 40, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { assert.EqualValues(t, s.expected, calculateOrigin(s.origin, s.bufferHeight, s.numLines, s.firstLineIdx, s.lastLineIdx, s.selectedLineIdx, s.selectMode)) }) } } lazygit-0.50.0+ds1/pkg/gui/patch_exploring/state.go000066400000000000000000000211451500612110400221350ustar00rootroot00000000000000package patch_exploring import ( "strings" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // State represents the current state of the patch explorer context i.e. when // you're staging a file or you're building a patch from an existing commit // this struct holds the info about the diff you're interacting with and what's currently selected. type State struct { // These are in terms of view lines (wrapped), not patch lines selectedLineIdx int rangeStartLineIdx int // If a range is sticky, it means we expand the range when we move up or down. // Otherwise, we cancel the range when we move up or down. rangeIsSticky bool diff string patch *patch.Patch selectMode selectMode // Array of indices of the wrapped lines indexed by a patch line index viewLineIndices []int // Array of indices of the original patch lines indexed by a wrapped view line index patchLineIndices []int } // these represent what select mode we're in type selectMode int const ( LINE selectMode = iota RANGE HUNK ) func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *State) *State { if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 { // if we're here then we can return the old state. If selectedLineIdx was not -1 // then that would mean we were trying to click and potentially drag a range, which // is why in that case we continue below return oldState } patch := patch.Parse(diff) if !patch.ContainsChanges() { return nil } viewLineIndices, patchLineIndices := wrapPatchLines(diff, view) rangeStartLineIdx := 0 if oldState != nil { rangeStartLineIdx = oldState.rangeStartLineIdx } selectMode := LINE // if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line if selectedLineIdx >= 0 { // Clamp to the number of wrapped view lines; index might be out of // bounds if a custom pager is being used which produces more lines selectedLineIdx = min(selectedLineIdx, len(viewLineIndices)-1) selectMode = RANGE rangeStartLineIdx = selectedLineIdx } else if oldState != nil { // if we previously had a selectMode of RANGE, we want that to now be line again if oldState.selectMode == HUNK { selectMode = HUNK } selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(oldState.patchLineIndices[oldState.selectedLineIdx])] } else { selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(0)] } return &State{ patch: patch, selectedLineIdx: selectedLineIdx, selectMode: selectMode, rangeStartLineIdx: rangeStartLineIdx, rangeIsSticky: false, diff: diff, viewLineIndices: viewLineIndices, patchLineIndices: patchLineIndices, } } func (s *State) OnViewWidthChanged(view *gocui.View) { if !view.Wrap { return } selectedPatchLineIdx := s.patchLineIndices[s.selectedLineIdx] var rangeStartPatchLineIdx int if s.selectMode == RANGE { rangeStartPatchLineIdx = s.patchLineIndices[s.rangeStartLineIdx] } s.viewLineIndices, s.patchLineIndices = wrapPatchLines(s.diff, view) s.selectedLineIdx = s.viewLineIndices[selectedPatchLineIdx] if s.selectMode == RANGE { s.rangeStartLineIdx = s.viewLineIndices[rangeStartPatchLineIdx] } } func (s *State) GetSelectedPatchLineIdx() int { return s.patchLineIndices[s.selectedLineIdx] } func (s *State) GetSelectedViewLineIdx() int { return s.selectedLineIdx } func (s *State) GetDiff() string { return s.diff } func (s *State) ToggleSelectHunk() { if s.selectMode == HUNK { s.selectMode = LINE } else { s.selectMode = HUNK } } func (s *State) ToggleStickySelectRange() { s.ToggleSelectRange(true) } func (s *State) ToggleSelectRange(sticky bool) { if s.SelectingRange() { s.selectMode = LINE } else { s.selectMode = RANGE s.rangeStartLineIdx = s.selectedLineIdx s.rangeIsSticky = sticky } } func (s *State) SetRangeIsSticky(value bool) { s.rangeIsSticky = value } func (s *State) SelectingHunk() bool { return s.selectMode == HUNK } func (s *State) SelectingRange() bool { return s.selectMode == RANGE && (s.rangeIsSticky || s.rangeStartLineIdx != s.selectedLineIdx) } func (s *State) SelectingLine() bool { return s.selectMode == LINE } func (s *State) SetLineSelectMode() { s.selectMode = LINE } func (s *State) DismissHunkSelectMode() { if s.SelectingHunk() { s.selectMode = LINE } } // For when you move the cursor without holding shift (meaning if we're in // a non-sticky range select, we'll cancel it) func (s *State) SelectLine(newSelectedLineIdx int) { if s.selectMode == RANGE && !s.rangeIsSticky { s.selectMode = LINE } s.selectLineWithoutRangeCheck(newSelectedLineIdx) } func (s *State) clampLineIdx(lineIdx int) int { return lo.Clamp(lineIdx, 0, len(s.patchLineIndices)-1) } // This just moves the cursor without caring about range select func (s *State) selectLineWithoutRangeCheck(newSelectedLineIdx int) { s.selectedLineIdx = s.clampLineIdx(newSelectedLineIdx) } func (s *State) SelectNewLineForRange(newSelectedLineIdx int) { s.rangeStartLineIdx = s.clampLineIdx(newSelectedLineIdx) s.selectMode = RANGE s.selectLineWithoutRangeCheck(newSelectedLineIdx) } func (s *State) DragSelectLine(newSelectedLineIdx int) { s.selectMode = RANGE s.selectLineWithoutRangeCheck(newSelectedLineIdx) } func (s *State) CycleSelection(forward bool) { if s.SelectingHunk() { s.CycleHunk(forward) } else { s.CycleLine(forward) } } func (s *State) CycleHunk(forward bool) { change := 1 if !forward { change = -1 } hunkIdx := s.patch.HunkContainingLine(s.patchLineIndices[s.selectedLineIdx]) if hunkIdx != -1 { newHunkIdx := hunkIdx + change if newHunkIdx >= 0 && newHunkIdx < s.patch.HunkCount() { start := s.patch.HunkStartIdx(newHunkIdx) s.selectedLineIdx = s.viewLineIndices[s.patch.GetNextChangeIdx(start)] } } } func (s *State) CycleLine(forward bool) { change := 1 if !forward { change = -1 } s.SelectLine(s.selectedLineIdx + change) } // This is called when we use shift+arrow to expand the range (i.e. a non-sticky // range) func (s *State) CycleRange(forward bool) { if !s.SelectingRange() { s.ToggleSelectRange(false) } s.SetRangeIsSticky(false) change := 1 if !forward { change = -1 } s.selectLineWithoutRangeCheck(s.selectedLineIdx + change) } // returns first and last patch line index of current hunk func (s *State) CurrentHunkBounds() (int, int) { hunkIdx := s.patch.HunkContainingLine(s.patchLineIndices[s.selectedLineIdx]) start := s.patch.HunkStartIdx(hunkIdx) end := s.patch.HunkEndIdx(hunkIdx) return start, end } func (s *State) SelectedViewRange() (int, int) { switch s.selectMode { case HUNK: start, end := s.CurrentHunkBounds() return s.viewLineIndices[start], s.viewLineIndices[end] case RANGE: if s.rangeStartLineIdx > s.selectedLineIdx { return s.selectedLineIdx, s.rangeStartLineIdx } else { return s.rangeStartLineIdx, s.selectedLineIdx } case LINE: return s.selectedLineIdx, s.selectedLineIdx default: // should never happen return 0, 0 } } func (s *State) SelectedPatchRange() (int, int) { start, end := s.SelectedViewRange() return s.patchLineIndices[start], s.patchLineIndices[end] } func (s *State) CurrentLineNumber() int { return s.patch.LineNumberOfLine(s.patchLineIndices[s.selectedLineIdx]) } func (s *State) AdjustSelectedLineIdx(change int) { s.DismissHunkSelectMode() s.SelectLine(s.selectedLineIdx + change) } func (s *State) RenderForLineIndices(includedLineIndices []int) string { includedLineIndicesSet := set.NewFromSlice(includedLineIndices) return s.patch.FormatView(patch.FormatViewOpts{ IncLineIndices: includedLineIndicesSet, }) } func (s *State) PlainRenderSelected() string { firstLineIdx, lastLineIdx := s.SelectedPatchRange() return s.patch.FormatRangePlain(firstLineIdx, lastLineIdx) } func (s *State) SelectBottom() { s.DismissHunkSelectMode() s.SelectLine(len(s.patchLineIndices) - 1) } func (s *State) SelectTop() { s.DismissHunkSelectMode() s.SelectLine(0) } func (s *State) CalculateOrigin(currentOrigin int, bufferHeight int, numLines int) int { firstLineIdx, lastLineIdx := s.SelectedViewRange() return calculateOrigin(currentOrigin, bufferHeight, numLines, firstLineIdx, lastLineIdx, s.GetSelectedViewLineIdx(), s.selectMode) } func wrapPatchLines(diff string, view *gocui.View) ([]int, []int) { _, viewLineIndices, patchLineIndices := utils.WrapViewLinesToWidth( view.Wrap, view.Editable, strings.TrimSuffix(diff, "\n"), view.InnerWidth(), view.TabWidth) return viewLineIndices, patchLineIndices } lazygit-0.50.0+ds1/pkg/gui/popup/000077500000000000000000000000001500612110400164405ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/popup/popup_handler.go000066400000000000000000000077601500612110400216410ustar00rootroot00000000000000package popup import ( "context" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" ) type PopupHandler struct { *common.Common createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts) onErrorFn func() error popContextFn func() currentContextFn func() types.Context createMenuFn func(types.CreateMenuOptions) error withWaitingStatusFn func(message string, f func(gocui.Task) error) withWaitingStatusSyncFn func(message string, f func() error) error toastFn func(message string, kind types.ToastKind) getPromptInputFn func() string inDemo func() bool } var _ types.IPopupHandler = &PopupHandler{} func NewPopupHandler( common *common.Common, createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts), onErrorFn func() error, popContextFn func(), currentContextFn func() types.Context, createMenuFn func(types.CreateMenuOptions) error, withWaitingStatusFn func(message string, f func(gocui.Task) error), withWaitingStatusSyncFn func(message string, f func() error) error, toastFn func(message string, kind types.ToastKind), getPromptInputFn func() string, inDemo func() bool, ) *PopupHandler { return &PopupHandler{ Common: common, createPopupPanelFn: createPopupPanelFn, onErrorFn: onErrorFn, popContextFn: popContextFn, currentContextFn: currentContextFn, createMenuFn: createMenuFn, withWaitingStatusFn: withWaitingStatusFn, withWaitingStatusSyncFn: withWaitingStatusSyncFn, toastFn: toastFn, getPromptInputFn: getPromptInputFn, inDemo: inDemo, } } func (self *PopupHandler) Menu(opts types.CreateMenuOptions) error { return self.createMenuFn(opts) } func (self *PopupHandler) Toast(message string) { self.toastFn(message, types.ToastKindStatus) } func (self *PopupHandler) ErrorToast(message string) { self.toastFn(message, types.ToastKindError) } func (self *PopupHandler) SetToastFunc(f func(string, types.ToastKind)) { self.toastFn = f } func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) error) error { self.withWaitingStatusFn(message, f) return nil } func (self *PopupHandler) WithWaitingStatusSync(message string, f func() error) error { return self.withWaitingStatusSyncFn(message, f) } func (self *PopupHandler) ErrorHandler(err error) error { // Need to set bold here explicitly; otherwise it gets cancelled by the red colouring. coloredMessage := style.FgRed.SetBold().Sprint(strings.TrimSpace(err.Error())) if err := self.onErrorFn(); err != nil { return err } self.Alert(self.Tr.Error, coloredMessage) return nil } func (self *PopupHandler) Alert(title string, message string) { self.Confirm(types.ConfirmOpts{Title: title, Prompt: message}) } func (self *PopupHandler) Confirm(opts types.ConfirmOpts) { self.createPopupPanelFn(context.Background(), types.CreatePopupPanelOpts{ Title: opts.Title, Prompt: opts.Prompt, HandleConfirm: opts.HandleConfirm, HandleClose: opts.HandleClose, }) } func (self *PopupHandler) Prompt(opts types.PromptOpts) { self.createPopupPanelFn(context.Background(), types.CreatePopupPanelOpts{ Title: opts.Title, Prompt: opts.InitialContent, Editable: true, HandleConfirmPrompt: opts.HandleConfirm, HandleClose: opts.HandleClose, HandleDeleteSuggestion: opts.HandleDeleteSuggestion, FindSuggestionsFunc: opts.FindSuggestionsFunc, AllowEditSuggestion: opts.AllowEditSuggestion, Mask: opts.Mask, }) } // returns the content that has currently been typed into the prompt. Useful for // asynchronously updating the suggestions list under the prompt. func (self *PopupHandler) GetPromptInput() string { return self.getPromptInputFn() } lazygit-0.50.0+ds1/pkg/gui/presentation/000077500000000000000000000000001500612110400200105ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/presentation/authors/000077500000000000000000000000001500612110400214755ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/presentation/authors/authors.go000066400000000000000000000065171500612110400235220ustar00rootroot00000000000000package authors import ( "crypto/md5" "strings" "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/lucasb-eyer/go-colorful" "github.com/mattn/go-runewidth" ) type authorNameCacheKey struct { authorName string truncateTo int } // if these being global variables causes trouble we can wrap them in a struct // attached to the gui state. var ( authorInitialCache = make(map[string]string) authorNameCache = make(map[authorNameCacheKey]string) authorStyleCache = make(map[string]*style.TextStyle) ) const authorNameWildcard = "*" func ShortAuthor(authorName string) string { if value, ok := authorInitialCache[authorName]; ok { return value } initials := getInitials(authorName) if initials == "" { return "" } value := AuthorStyle(authorName).Sprint(initials) authorInitialCache[authorName] = value return value } func LongAuthor(authorName string, length int) string { cacheKey := authorNameCacheKey{authorName: authorName, truncateTo: length} if value, ok := authorNameCache[cacheKey]; ok { return value } paddedAuthorName := utils.WithPadding(authorName, length, utils.AlignLeft) truncatedName := utils.TruncateWithEllipsis(paddedAuthorName, length) value := AuthorStyle(authorName).Sprint(truncatedName) authorNameCache[cacheKey] = value return value } // AuthorWithLength returns a representation of the author that fits into a // given maximum length: // - if the length is less than 2, it returns an empty string // - if the length is 2, it returns the initials // - otherwise, it returns the author name truncated to the maximum length func AuthorWithLength(authorName string, length int) string { if length < 2 { return "" } if length == 2 { return ShortAuthor(authorName) } return LongAuthor(authorName, length) } func AuthorStyle(authorName string) *style.TextStyle { if value, ok := authorStyleCache[authorName]; ok { return value } // use the unified style whatever the author name is if value, ok := authorStyleCache[authorNameWildcard]; ok { return value } value := trueColorStyle(authorName) authorStyleCache[authorName] = &value return &value } func trueColorStyle(str string) style.TextStyle { hash := md5.Sum([]byte(str)) c := colorful.Hsl(randFloat(hash[0:4])*360.0, 0.6+0.4*randFloat(hash[4:8]), 0.4+randFloat(hash[8:12])*0.2) return style.New().SetFg(style.NewRGBColor(color.RGB(uint8(c.R*255), uint8(c.G*255), uint8(c.B*255)))) } func randFloat(hash []byte) float64 { return float64(randInt(hash, 100)) / 100 } func randInt(hash []byte, max int) int { sum := 0 for _, b := range hash { sum = (sum + int(b)) % max } return sum } func getInitials(authorName string) string { if authorName == "" { return authorName } firstRune := getFirstRune(authorName) if runewidth.RuneWidth(firstRune) > 1 { return string(firstRune) } split := strings.Split(authorName, " ") if len(split) == 1 { return utils.LimitStr(authorName, 2) } return utils.LimitStr(split[0], 1) + utils.LimitStr(split[1], 1) } func getFirstRune(str string) rune { // just using the loop for the sake of getting the first rune for _, r := range str { return r } // should never land here return 0 } func SetCustomAuthors(customAuthorColors map[string]string) { authorStyleCache = utils.SetCustomColors(customAuthorColors) } lazygit-0.50.0+ds1/pkg/gui/presentation/authors/authors_test.go000066400000000000000000000020421500612110400245460ustar00rootroot00000000000000package authors import ( "testing" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) func TestGetInitials(t *testing.T) { for input, expectedOutput := range map[string]string{ "Jesse Duffield": "JD", "Jesse Duffield Man": "JD", "JesseDuffield": "Je", "J": "J", "六书六書": "六", "書": "書", "": "", } { output := getInitials(input) if output != expectedOutput { t.Errorf("Expected %s to be %s", output, expectedOutput) } } } func TestAuthorWithLength(t *testing.T) { scenarios := []struct { authorName string length int expectedOutput string }{ {"Jesse Duffield", 0, ""}, {"Jesse Duffield", 1, ""}, {"Jesse Duffield", 2, "JD"}, {"Jesse Duffield", 3, "Je…"}, {"Jesse Duffield", 10, "Jesse Duf…"}, {"Jesse Duffield", 14, "Jesse Duffield"}, } for _, s := range scenarios { assert.Equal(t, s.expectedOutput, utils.Decolorise(AuthorWithLength(s.authorName, s.length))) } } lazygit-0.50.0+ds1/pkg/gui/presentation/branches.go000066400000000000000000000137211500612110400221300ustar00rootroot00000000000000package presentation import ( "fmt" "regexp" "strings" "time" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/mattn/go-runewidth" "github.com/samber/lo" ) type colorMatcher struct { patterns map[string]*style.TextStyle isRegex bool // NOTE: this value is needed only until the deprecated branchColors config is removed and only regex color patterns are used } var colorPatterns *colorMatcher func GetBranchListDisplayStrings( branches []*models.Branch, getItemOperation func(item types.HasUrn) types.ItemOperation, fullDescription bool, diffName string, viewWidth int, tr *i18n.TranslationSet, userConfig *config.UserConfig, worktrees []*models.Worktree, ) [][]string { return lo.Map(branches, func(branch *models.Branch, _ int) []string { diffed := branch.Name == diffName return getBranchDisplayStrings(branch, getItemOperation(branch), fullDescription, diffed, viewWidth, tr, userConfig, worktrees, time.Now()) }) } // getBranchDisplayStrings returns the display string of branch func getBranchDisplayStrings( b *models.Branch, itemOperation types.ItemOperation, fullDescription bool, diffed bool, viewWidth int, tr *i18n.TranslationSet, userConfig *config.UserConfig, worktrees []*models.Worktree, now time.Time, ) []string { checkedOutByWorkTree := git_commands.CheckedOutByOtherWorktree(b, worktrees) showCommitHash := fullDescription || userConfig.Gui.ShowBranchCommitHash branchStatus := BranchStatus(b, itemOperation, tr, now, userConfig) worktreeIcon := lo.Ternary(icons.IsIconEnabled(), icons.LINKED_WORKTREE_ICON, fmt.Sprintf("(%s)", tr.LcWorktree)) // Recency is always three characters, plus one for the space availableWidth := viewWidth - 4 if len(branchStatus) > 0 { availableWidth -= utils.StringWidth(utils.Decolorise(branchStatus)) + 1 } if icons.IsIconEnabled() { availableWidth -= 2 // one for the icon, one for the space } if showCommitHash { availableWidth -= utils.COMMIT_HASH_SHORT_SIZE + 1 } if checkedOutByWorkTree { availableWidth -= utils.StringWidth(worktreeIcon) + 1 } displayName := b.Name if b.DisplayName != "" { displayName = b.DisplayName } nameTextStyle := GetBranchTextStyle(b.Name) if diffed { nameTextStyle = theme.DiffTerminalColor } // Don't bother shortening branch names that are already 3 characters or less if utils.StringWidth(displayName) > max(availableWidth, 3) { // Never shorten the branch name to less then 3 characters len := max(availableWidth, 4) displayName = runewidth.Truncate(displayName, len, "…") } coloredName := nameTextStyle.Sprint(displayName) if checkedOutByWorkTree { coloredName = fmt.Sprintf("%s %s", coloredName, style.FgDefault.Sprint(worktreeIcon)) } if len(branchStatus) > 0 { coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus) } recencyColor := style.FgCyan if b.Recency == " *" { recencyColor = style.FgGreen } res := make([]string, 0, 6) res = append(res, recencyColor.Sprint(b.Recency)) if icons.IsIconEnabled() { res = append(res, nameTextStyle.Sprint(icons.IconForBranch(b))) } if showCommitHash { res = append(res, utils.ShortHash(b.CommitHash)) } res = append(res, coloredName) if fullDescription { res = append( res, fmt.Sprintf("%s %s", style.FgYellow.Sprint(b.UpstreamRemote), style.FgYellow.Sprint(b.UpstreamBranch), ), utils.TruncateWithEllipsis(b.Subject, 60), ) } return res } // GetBranchTextStyle branch color func GetBranchTextStyle(name string) style.TextStyle { if style, ok := colorPatterns.match(name); ok { return *style } return theme.DefaultTextColor } func (m *colorMatcher) match(name string) (*style.TextStyle, bool) { if m.isRegex { for pattern, style := range m.patterns { if matched, _ := regexp.MatchString(pattern, name); matched { return style, true } } } else { // old behavior using the deprecated branchColors behavior matching on branch type branchType := strings.Split(name, "/")[0] if value, ok := m.patterns[branchType]; ok { return value, true } } return nil, false } func BranchStatus( branch *models.Branch, itemOperation types.ItemOperation, tr *i18n.TranslationSet, now time.Time, userConfig *config.UserConfig, ) string { itemOperationStr := ItemOperationToString(itemOperation, tr) if itemOperationStr != "" { return style.FgCyan.Sprintf("%s %s", itemOperationStr, utils.Loader(now, userConfig.Gui.Spinner)) } result := "" if branch.IsTrackingRemote() { if branch.UpstreamGone { result = style.FgRed.Sprint(tr.UpstreamGone) } else if branch.MatchesUpstream() { result = style.FgGreen.Sprint("✓") } else if branch.RemoteBranchNotStoredLocally() { result = style.FgMagenta.Sprint("?") } else if branch.IsBehindForPull() && branch.IsAheadForPull() { result = style.FgYellow.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull) } else if branch.IsBehindForPull() { result = style.FgYellow.Sprintf("↓%s", branch.BehindForPull) } else if branch.IsAheadForPull() { result = style.FgYellow.Sprintf("↑%s", branch.AheadForPull) } } if userConfig.Gui.ShowDivergenceFromBaseBranch != "none" { behind := branch.BehindBaseBranch.Load() if behind != 0 { if result != "" { result += " " } if userConfig.Gui.ShowDivergenceFromBaseBranch == "arrowAndNumber" { result += style.FgCyan.Sprintf("↓%d", behind) } else { result += style.FgCyan.Sprintf("↓") } } } return result } func SetCustomBranches(customBranchColors map[string]string, isRegex bool) { colorPatterns = &colorMatcher{ patterns: utils.SetCustomColors(customBranchColors), isRegex: isRegex, } } lazygit-0.50.0+ds1/pkg/gui/presentation/branches_test.go000066400000000000000000000252411500612110400231670ustar00rootroot00000000000000package presentation import ( "fmt" "sync/atomic" "testing" "time" "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/xo/terminfo" ) func makeAtomic(v int32) (result atomic.Int32) { result.Store(v) return //nolint: nakedret } func Test_getBranchDisplayStrings(t *testing.T) { scenarios := []struct { branch *models.Branch itemOperation types.ItemOperation fullDescription bool viewWidth int useIcons bool checkedOutByWorktree bool showDivergenceCfg string expected []string }{ // First some tests for when the view is wide enough so that everything fits: { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 100, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "branch_name"}, }, { branch: &models.Branch{Name: "🍉_special_char", Recency: "1m"}, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 19, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "🍉_special_char"}, }, { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 100, useIcons: false, checkedOutByWorktree: true, showDivergenceCfg: "none", expected: []string{"1m", "branch_name (worktree)"}, }, { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 100, useIcons: true, checkedOutByWorktree: true, showDivergenceCfg: "none", expected: []string{"1m", "󰘬", "branch_name 󰌹"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", UpstreamRemote: "origin", AheadForPull: "0", BehindForPull: "0", }, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 100, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "branch_name ✓"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", UpstreamRemote: "origin", AheadForPull: "3", BehindForPull: "5", }, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 100, useIcons: false, checkedOutByWorktree: true, showDivergenceCfg: "none", expected: []string{"1m", "branch_name (worktree) ↓5↑3"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", BehindBaseBranch: makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 100, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "onlyArrow", expected: []string{"1m", "branch_name ↓"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", UpstreamRemote: "origin", AheadForPull: "0", BehindForPull: "0", BehindBaseBranch: makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 100, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "arrowAndNumber", expected: []string{"1m", "branch_name ✓ ↓2"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", UpstreamRemote: "origin", AheadForPull: "3", BehindForPull: "5", BehindBaseBranch: makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 100, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "arrowAndNumber", expected: []string{"1m", "branch_name ↓5↑3 ↓2"}, }, { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, itemOperation: types.ItemOperationPushing, fullDescription: false, viewWidth: 100, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "branch_name Pushing |"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", CommitHash: "1234567890", UpstreamRemote: "origin", UpstreamBranch: "branch_name", AheadForPull: "0", BehindForPull: "0", Subject: "commit title", }, itemOperation: types.ItemOperationNone, fullDescription: true, viewWidth: 100, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "12345678", "branch_name ✓", "origin branch_name", "commit title"}, }, // Now tests for how we truncate the branch name when there's not enough room: { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 14, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "branch_na…"}, }, { branch: &models.Branch{Name: "🍉_special_char", Recency: "1m"}, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 18, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "🍉_special_ch…"}, }, { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 14, useIcons: false, checkedOutByWorktree: true, showDivergenceCfg: "none", expected: []string{"1m", "bra… (worktree)"}, }, { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 14, useIcons: true, checkedOutByWorktree: true, showDivergenceCfg: "none", expected: []string{"1m", "󰘬", "branc… 󰌹"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", UpstreamRemote: "origin", AheadForPull: "0", BehindForPull: "0", }, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 14, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "branch_… ✓"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", UpstreamRemote: "origin", AheadForPull: "3", BehindForPull: "5", }, itemOperation: types.ItemOperationNone, fullDescription: false, viewWidth: 30, useIcons: false, checkedOutByWorktree: true, showDivergenceCfg: "none", expected: []string{"1m", "branch_na… (worktree) ↓5↑3"}, }, { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, itemOperation: types.ItemOperationPushing, fullDescription: false, viewWidth: 20, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "branc… Pushing |"}, }, { branch: &models.Branch{Name: "abc", Recency: "1m"}, itemOperation: types.ItemOperationPushing, fullDescription: false, viewWidth: -1, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "abc Pushing |"}, }, { branch: &models.Branch{Name: "ab", Recency: "1m"}, itemOperation: types.ItemOperationPushing, fullDescription: false, viewWidth: -1, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "ab Pushing |"}, }, { branch: &models.Branch{Name: "a", Recency: "1m"}, itemOperation: types.ItemOperationPushing, fullDescription: false, viewWidth: -1, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "a Pushing |"}, }, { branch: &models.Branch{ Name: "branch_name", Recency: "1m", CommitHash: "1234567890", UpstreamRemote: "origin", UpstreamBranch: "branch_name", AheadForPull: "0", BehindForPull: "0", Subject: "commit title", }, itemOperation: types.ItemOperationNone, fullDescription: true, viewWidth: 20, useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", expected: []string{"1m", "12345678", "bran… ✓", "origin branch_name", "commit title"}, }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) defer color.ForceSetColorLevel(oldColorLevel) c := utils.NewDummyCommon() SetCustomBranches(c.UserConfig().Gui.BranchColorPatterns, true) for i, s := range scenarios { icons.SetNerdFontsVersion(lo.Ternary(s.useIcons, "3", "")) c.UserConfig().Gui.ShowDivergenceFromBaseBranch = s.showDivergenceCfg worktrees := []*models.Worktree{} if s.checkedOutByWorktree { worktrees = append(worktrees, &models.Worktree{Branch: s.branch.Name}) } t.Run(fmt.Sprintf("getBranchDisplayStrings_%d", i), func(t *testing.T) { strings := getBranchDisplayStrings(s.branch, s.itemOperation, s.fullDescription, false, s.viewWidth, c.Tr, c.UserConfig(), worktrees, time.Time{}) assert.Equal(t, s.expected, strings) }) } } lazygit-0.50.0+ds1/pkg/gui/presentation/commits.go000066400000000000000000000352651500612110400220250ustar00rootroot00000000000000package presentation import ( "fmt" "strings" "time" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/presentation/authors" "github.com/jesseduffield/lazygit/pkg/gui/presentation/graph" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/kyokomi/emoji/v2" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" "github.com/stefanhaller/git-todo-parser/todo" ) type pipeSetCacheKey struct { commitHash string commitCount int divergence models.Divergence } var ( pipeSetCache = make(map[pipeSetCacheKey][][]graph.Pipe) mutex deadlock.Mutex ) type bisectBounds struct { newIndex int oldIndex int } func GetCommitListDisplayStrings( common *common.Common, commits []*models.Commit, branches []*models.Branch, currentBranchName string, hasRebaseUpdateRefsConfig bool, fullDescription bool, cherryPickedCommitHashSet *set.Set[string], diffName string, markedBaseCommit string, timeFormat string, shortTimeFormat string, now time.Time, parseEmoji bool, selectedCommitHashPtr *string, startIdx int, endIdx int, showGraph bool, bisectInfo *git_commands.BisectInfo, ) [][]string { mutex.Lock() defer mutex.Unlock() if len(commits) == 0 { return nil } if startIdx >= len(commits) { return nil } // this is where my non-TODO commits begin rebaseOffset := min(indexOfFirstNonTODOCommit(commits), endIdx) filteredCommits := commits[startIdx:endIdx] bisectBounds := getbisectBounds(commits, bisectInfo) // function expects to be passed the index of the commit in terms of the `commits` slice var getGraphLine func(int) string if showGraph { if len(commits) > 0 && commits[0].Divergence != models.DivergenceNone { // Showing a divergence log; we know we don't have any rebasing // commits in this case. But we need to render separate graphs for // the Local and Remote sections. allGraphLines := []string{} _, localSectionStart, found := lo.FindIndexOf( commits, func(c *models.Commit) bool { return c.Divergence == models.DivergenceLeft }) if !found { localSectionStart = len(commits) } if localSectionStart > 0 { // we have some remote commits pipeSets := loadPipesets(commits[:localSectionStart]) if startIdx < localSectionStart { // some of the remote commits are visible start := startIdx end := min(endIdx, localSectionStart) graphPipeSets := pipeSets[start:end] graphCommits := commits[start:end] graphLines := graph.RenderAux( graphPipeSets, graphCommits, selectedCommitHashPtr, ) allGraphLines = append(allGraphLines, graphLines...) } } if localSectionStart < len(commits) { // we have some local commits pipeSets := loadPipesets(commits[localSectionStart:]) if localSectionStart < endIdx { // some of the local commits are visible graphOffset := max(startIdx, localSectionStart) pipeSetOffset := max(startIdx-localSectionStart, 0) graphPipeSets := pipeSets[pipeSetOffset : endIdx-localSectionStart] graphCommits := commits[graphOffset:endIdx] graphLines := graph.RenderAux( graphPipeSets, graphCommits, selectedCommitHashPtr, ) allGraphLines = append(allGraphLines, graphLines...) } } getGraphLine = func(idx int) string { return allGraphLines[idx-startIdx] } } else { // this is where the graph begins (may be beyond the TODO commits depending on startIdx, // but we'll never include TODO commits as part of the graph because it'll be messy) graphOffset := max(startIdx, rebaseOffset) pipeSets := loadPipesets(commits[rebaseOffset:]) pipeSetOffset := max(startIdx-rebaseOffset, 0) graphPipeSets := pipeSets[pipeSetOffset:max(endIdx-rebaseOffset, 0)] graphCommits := commits[graphOffset:endIdx] graphLines := graph.RenderAux( graphPipeSets, graphCommits, selectedCommitHashPtr, ) getGraphLine = func(idx int) string { if idx >= graphOffset { return graphLines[idx-graphOffset] } else { return "" } } } } else { getGraphLine = func(int) string { return "" } } // Determine the hashes of the local branches for which we want to show a // branch marker in the commits list. We only want to do this for branches // that are not the current branch, and not any of the main branches. The // goal is to visualize stacks of local branches, so anything that doesn't // contribute to a branch stack shouldn't show a marker. // // If there are other branches pointing to the current head commit, we only // want to show the marker if the rebase.updateRefs config is on. branchHeadsToVisualize := set.NewFromSlice(lo.FilterMap(branches, func(b *models.Branch, index int) (string, bool) { return b.CommitHash, // Don't consider branches that don't have a commit hash. As far // as I can see, this happens for a detached head, so filter // these out b.CommitHash != "" && // Don't show a marker for the current branch b.Name != currentBranchName && // Don't show a marker for main branches !lo.Contains(common.UserConfig().Git.MainBranches, b.Name) && // Don't show a marker for the head commit unless the // rebase.updateRefs config is on (hasRebaseUpdateRefsConfig || b.CommitHash != commits[0].Hash()) })) lines := make([][]string, 0, len(filteredCommits)) var bisectStatus BisectStatus willBeRebased := markedBaseCommit == "" for i, commit := range filteredCommits { unfilteredIdx := i + startIdx bisectStatus = getBisectStatus(unfilteredIdx, commit.Hash(), bisectInfo, bisectBounds) isMarkedBaseCommit := commit.Hash() != "" && commit.Hash() == markedBaseCommit if isMarkedBaseCommit { willBeRebased = true } lines = append(lines, displayCommit( common, commit, branchHeadsToVisualize, hasRebaseUpdateRefsConfig, cherryPickedCommitHashSet, isMarkedBaseCommit, willBeRebased, diffName, timeFormat, shortTimeFormat, now, parseEmoji, getGraphLine(unfilteredIdx), fullDescription, bisectStatus, bisectInfo, )) } return lines } func getbisectBounds(commits []*models.Commit, bisectInfo *git_commands.BisectInfo) *bisectBounds { if !bisectInfo.Bisecting() { return nil } bisectBounds := &bisectBounds{} for i, commit := range commits { if commit.Hash() == bisectInfo.GetNewHash() { bisectBounds.newIndex = i } status, ok := bisectInfo.Status(commit.Hash()) if ok && status == git_commands.BisectStatusOld { bisectBounds.oldIndex = i return bisectBounds } } // shouldn't land here return nil } // precondition: slice is not empty func indexOfFirstNonTODOCommit(commits []*models.Commit) int { for i, commit := range commits { if !commit.IsTODO() { return i } } // shouldn't land here return 0 } func loadPipesets(commits []*models.Commit) [][]graph.Pipe { // given that our cache key is a commit hash and a commit count, it's very important that we don't actually try to render pipes // when dealing with things like filtered commits. cacheKey := pipeSetCacheKey{ commitHash: commits[0].Hash(), commitCount: len(commits), divergence: commits[0].Divergence, } pipeSets, ok := pipeSetCache[cacheKey] if !ok { // pipe sets are unique to a commit head. and a commit count. Sometimes we haven't loaded everything for that. // so let's just cache it based on that. getStyle := func(commit *models.Commit) *style.TextStyle { return authors.AuthorStyle(commit.AuthorName) } pipeSets = graph.GetPipeSets(commits, getStyle) pipeSetCache[cacheKey] = pipeSets } return pipeSets } // similar to the git_commands.BisectStatus but more gui-focused type BisectStatus int const ( BisectStatusNone BisectStatus = iota BisectStatusOld BisectStatusNew BisectStatusSkipped // adding candidate here which isn't present in the commands package because // we need to actually go through the commits to get this info BisectStatusCandidate // also adding this BisectStatusCurrent ) func getBisectStatus(index int, commitHash string, bisectInfo *git_commands.BisectInfo, bisectBounds *bisectBounds) BisectStatus { if !bisectInfo.Started() { return BisectStatusNone } if bisectInfo.GetCurrentHash() == commitHash { return BisectStatusCurrent } status, ok := bisectInfo.Status(commitHash) if ok { switch status { case git_commands.BisectStatusNew: return BisectStatusNew case git_commands.BisectStatusOld: return BisectStatusOld case git_commands.BisectStatusSkipped: return BisectStatusSkipped } } else { if bisectBounds != nil && index >= bisectBounds.newIndex && index <= bisectBounds.oldIndex { return BisectStatusCandidate } else { return BisectStatusNone } } // should never land here return BisectStatusNone } func getBisectStatusText(bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo) string { if bisectStatus == BisectStatusNone { return "" } style := getBisectStatusColor(bisectStatus) switch bisectStatus { case BisectStatusNew: return style.Sprintf("<-- " + bisectInfo.NewTerm()) case BisectStatusOld: return style.Sprintf("<-- " + bisectInfo.OldTerm()) case BisectStatusCurrent: // TODO: i18n return style.Sprintf("<-- current") case BisectStatusSkipped: return style.Sprintf("<-- skipped") case BisectStatusCandidate: return style.Sprintf("?") case BisectStatusNone: return "" } return "" } func displayCommit( common *common.Common, commit *models.Commit, branchHeadsToVisualize *set.Set[string], hasRebaseUpdateRefsConfig bool, cherryPickedCommitHashSet *set.Set[string], isMarkedBaseCommit bool, willBeRebased bool, diffName string, timeFormat string, shortTimeFormat string, now time.Time, parseEmoji bool, graphLine string, fullDescription bool, bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo, ) []string { bisectString := getBisectStatusText(bisectStatus, bisectInfo) hashString := "" hashColor := getHashColor(commit, diffName, cherryPickedCommitHashSet, bisectStatus, bisectInfo) hashLength := common.UserConfig().Gui.CommitHashLength if hashLength >= len(commit.Hash()) { hashString = hashColor.Sprint(commit.Hash()) } else if hashLength > 0 { hashString = hashColor.Sprint(commit.Hash()[:hashLength]) } else if !icons.IsIconEnabled() { // hashLength <= 0 hashString = hashColor.Sprint("*") } divergenceString := "" if commit.Divergence != models.DivergenceNone { divergenceString = hashColor.Sprint(lo.Ternary(commit.Divergence == models.DivergenceLeft, "↑", "↓")) } else if icons.IsIconEnabled() { divergenceString = hashColor.Sprint(icons.IconForCommit(commit)) } descriptionString := "" if fullDescription { descriptionString = style.FgBlue.Sprint( utils.UnixToDateSmart(now, commit.UnixTimestamp, timeFormat, shortTimeFormat), ) } actionString := "" if commit.Action != models.ActionNone { actionString = actionColorMap(commit.Action, commit.Status).Sprint(commit.Action.String()) } tagString := "" if fullDescription { if commit.ExtraInfo != "" { tagString = style.FgMagenta.SetBold().Sprint(commit.ExtraInfo) + " " } } else { if len(commit.Tags) > 0 { tagString = theme.DiffTerminalColor.SetBold().Sprint(strings.Join(commit.Tags, " ")) + " " } if branchHeadsToVisualize.Includes(commit.Hash()) && // Don't show branch head on commits that are already merged to a main branch commit.Status != models.StatusMerged && // Don't show branch head on a "pick" todo if the rebase.updateRefs config is on !(commit.IsTODO() && hasRebaseUpdateRefsConfig) { tagString = style.FgCyan.SetBold().Sprint( lo.Ternary(icons.IsIconEnabled(), icons.BRANCH_ICON, "*") + " " + tagString) } } name := commit.Name if commit.Action == todo.UpdateRef { name = strings.TrimPrefix(name, "refs/heads/") } if parseEmoji { name = emoji.Sprint(name) } mark := "" if commit.Status == models.StatusConflicted { youAreHere := style.FgRed.Sprintf("<-- %s ---", common.Tr.ConflictLabel) mark = fmt.Sprintf("%s ", youAreHere) } else if isMarkedBaseCommit { rebaseFromHere := style.FgYellow.Sprint(common.Tr.MarkedCommitMarker) mark = fmt.Sprintf("%s ", rebaseFromHere) } else if !willBeRebased { willBeRebased := style.FgYellow.Sprint("✓") mark = fmt.Sprintf("%s ", willBeRebased) } authorLength := common.UserConfig().Gui.CommitAuthorShortLength if fullDescription { authorLength = common.UserConfig().Gui.CommitAuthorLongLength } author := authors.AuthorWithLength(commit.AuthorName, authorLength) cols := make([]string, 0, 7) cols = append( cols, divergenceString, hashString, bisectString, descriptionString, actionString, author, graphLine+mark+tagString+theme.DefaultTextColor.Sprint(name), ) return cols } func getBisectStatusColor(status BisectStatus) style.TextStyle { switch status { case BisectStatusNone: return style.FgBlack case BisectStatusNew: return style.FgRed case BisectStatusOld: return style.FgGreen case BisectStatusSkipped: return style.FgYellow case BisectStatusCurrent: return style.FgMagenta case BisectStatusCandidate: return style.FgBlue } // shouldn't land here return style.FgWhite } func getHashColor( commit *models.Commit, diffName string, cherryPickedCommitHashSet *set.Set[string], bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo, ) style.TextStyle { if bisectInfo.Started() { return getBisectStatusColor(bisectStatus) } diffed := commit.Hash() != "" && commit.Hash() == diffName hashColor := theme.DefaultTextColor switch commit.Status { case models.StatusUnpushed: hashColor = style.FgRed case models.StatusPushed: hashColor = style.FgYellow case models.StatusMerged: hashColor = style.FgGreen case models.StatusRebasing, models.StatusCherryPickingOrReverting, models.StatusConflicted: hashColor = style.FgBlue case models.StatusReflog: hashColor = style.FgBlue default: } if diffed { hashColor = theme.DiffTerminalColor } else if cherryPickedCommitHashSet.Includes(commit.Hash()) { hashColor = theme.CherryPickedCommitTextStyle } else if commit.Divergence == models.DivergenceRight && commit.Status != models.StatusMerged { hashColor = style.FgBlue } return hashColor } func actionColorMap(action todo.TodoCommand, status models.CommitStatus) style.TextStyle { if status == models.StatusConflicted { return style.FgRed } switch action { case todo.Pick: return style.FgCyan case todo.Drop: return style.FgRed case todo.Edit: return style.FgGreen case todo.Fixup: return style.FgMagenta default: return style.FgYellow } } lazygit-0.50.0+ds1/pkg/gui/presentation/commits_test.go000066400000000000000000000561461500612110400230650ustar00rootroot00000000000000package presentation import ( "os" "strings" "testing" "time" "github.com/gookit/color" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" "github.com/stretchr/testify/assert" "github.com/xo/terminfo" ) func formatExpected(expected string) string { return strings.TrimSpace(strings.ReplaceAll(expected, "\t", "")) } func TestGetCommitListDisplayStrings(t *testing.T) { scenarios := []struct { testName string commitOpts []models.NewCommitOpts branches []*models.Branch currentBranchName string hasUpdateRefConfig bool fullDescription bool cherryPickedCommitHashSet *set.Set[string] markedBaseCommit string diffName string timeFormat string shortTimeFormat string now time.Time parseEmoji bool selectedCommitHashPtr *string startIdx int endIdx int showGraph bool bisectInfo *git_commands.BisectInfo expected string focus bool }{ { testName: "no commits", commitOpts: []models.NewCommitOpts{}, startIdx: 0, endIdx: 1, showGraph: false, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: "", }, { testName: "some commits", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1"}, {Name: "commit2", Hash: "hash2"}, }, startIdx: 0, endIdx: 2, showGraph: false, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 commit1 hash2 commit2 `), }, { testName: "commit with tags", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", Tags: []string{"tag1", "tag2"}}, {Name: "commit2", Hash: "hash2"}, }, startIdx: 0, endIdx: 2, showGraph: false, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 tag1 tag2 commit1 hash2 commit2 `), }, { testName: "show local branch head, except the current branch, main branches, or merged branches", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1"}, {Name: "commit2", Hash: "hash2"}, {Name: "commit3", Hash: "hash3"}, {Name: "commit4", Hash: "hash4", Status: models.StatusMerged}, }, branches: []*models.Branch{ {Name: "current-branch", CommitHash: "hash1", Head: true}, {Name: "other-branch", CommitHash: "hash2", Head: false}, {Name: "master", CommitHash: "hash3", Head: false}, {Name: "old-branch", CommitHash: "hash4", Head: false}, }, currentBranchName: "current-branch", hasUpdateRefConfig: true, startIdx: 0, endIdx: 4, showGraph: false, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 commit1 hash2 * commit2 hash3 commit3 hash4 commit4 `), }, { testName: "show local branch head for head commit if updateRefs is on", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1"}, {Name: "commit2", Hash: "hash2"}, }, branches: []*models.Branch{ {Name: "current-branch", CommitHash: "hash1", Head: true}, {Name: "other-branch", CommitHash: "hash1", Head: false}, }, currentBranchName: "current-branch", hasUpdateRefConfig: true, startIdx: 0, endIdx: 2, showGraph: false, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 * commit1 hash2 commit2 `), }, { testName: "don't show local branch head for head commit if updateRefs is off", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1"}, {Name: "commit2", Hash: "hash2"}, }, branches: []*models.Branch{ {Name: "current-branch", CommitHash: "hash1", Head: true}, {Name: "other-branch", CommitHash: "hash1", Head: false}, }, currentBranchName: "current-branch", hasUpdateRefConfig: false, startIdx: 0, endIdx: 2, showGraph: false, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 commit1 hash2 commit2 `), }, { testName: "show local branch head and tag if both exist", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1"}, {Name: "commit2", Hash: "hash2", Tags: []string{"some-tag"}}, {Name: "commit3", Hash: "hash3"}, }, branches: []*models.Branch{ {Name: "some-branch", CommitHash: "hash2"}, }, startIdx: 0, endIdx: 3, showGraph: false, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 commit1 hash2 * some-tag commit2 hash3 commit3 `), }, { testName: "showing graph", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}}, {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}}, {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, }, startIdx: 0, endIdx: 5, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 ⏣─╮ commit1 hash2 ◯ │ commit2 hash3 ◯─╯ commit3 hash4 ◯ commit4 hash5 ◯ commit5 `), }, { testName: "showing graph, including rebase commits", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}, Action: todo.Pick}, {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}, Action: todo.Pick}, {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, }, startIdx: 0, endIdx: 5, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 pick commit1 hash2 pick commit2 hash3 ◯ commit3 hash4 ◯ commit4 hash5 ◯ commit5 `), }, { testName: "showing graph, including rebase commits, with offset", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}, Action: todo.Pick}, {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}, Action: todo.Pick}, {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, }, startIdx: 1, endIdx: 5, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash2 pick commit2 hash3 ◯ commit3 hash4 ◯ commit4 hash5 ◯ commit5 `), }, { testName: "startIdx is past TODO commits", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}, Action: todo.Pick}, {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}, Action: todo.Pick}, {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, }, startIdx: 3, endIdx: 5, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash4 ◯ commit4 hash5 ◯ commit5 `), }, { testName: "only showing TODO commits", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}, Action: todo.Pick}, {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}, Action: todo.Pick}, {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, }, startIdx: 0, endIdx: 2, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 pick commit1 hash2 pick commit2 `), }, { testName: "no TODO commits, towards bottom", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}}, {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}}, {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, }, startIdx: 4, endIdx: 5, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash5 ◯ commit5 `), }, { testName: "only TODO commits except last", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}, Action: todo.Pick}, {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}, Action: todo.Pick}, {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}, Action: todo.Pick}, {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}, Action: todo.Pick}, {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, }, startIdx: 0, endIdx: 2, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` hash1 pick commit1 hash2 pick commit2 `), }, { testName: "graph in divergence view - all commits visible", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1r", Parents: []string{"hash2r"}, Divergence: models.DivergenceRight}, {Name: "commit2", Hash: "hash2r", Parents: []string{"hash3r", "hash5r"}, Divergence: models.DivergenceRight}, {Name: "commit3", Hash: "hash3r", Parents: []string{"hash4r"}, Divergence: models.DivergenceRight}, {Name: "commit1", Hash: "hash1l", Parents: []string{"hash2l"}, Divergence: models.DivergenceLeft}, {Name: "commit2", Hash: "hash2l", Parents: []string{"hash3l", "hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit3", Hash: "hash3l", Parents: []string{"hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit4", Hash: "hash4l", Parents: []string{"hash5l"}, Divergence: models.DivergenceLeft}, {Name: "commit5", Hash: "hash5l", Parents: []string{"hash6l"}, Divergence: models.DivergenceLeft}, }, startIdx: 0, endIdx: 8, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` ↓ hash1r ◯ commit1 ↓ hash2r ⏣─╮ commit2 ↓ hash3r ◯ │ commit3 ↑ hash1l ◯ commit1 ↑ hash2l ⏣─╮ commit2 ↑ hash3l ◯ │ commit3 ↑ hash4l ◯─╯ commit4 ↑ hash5l ◯ commit5 `), }, { testName: "graph in divergence view - not all remote commits visible", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1r", Parents: []string{"hash2r"}, Divergence: models.DivergenceRight}, {Name: "commit2", Hash: "hash2r", Parents: []string{"hash3r", "hash5r"}, Divergence: models.DivergenceRight}, {Name: "commit3", Hash: "hash3r", Parents: []string{"hash4r"}, Divergence: models.DivergenceRight}, {Name: "commit1", Hash: "hash1l", Parents: []string{"hash2l"}, Divergence: models.DivergenceLeft}, {Name: "commit2", Hash: "hash2l", Parents: []string{"hash3l", "hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit3", Hash: "hash3l", Parents: []string{"hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit4", Hash: "hash4l", Parents: []string{"hash5l"}, Divergence: models.DivergenceLeft}, {Name: "commit5", Hash: "hash5l", Parents: []string{"hash6l"}, Divergence: models.DivergenceLeft}, }, startIdx: 2, endIdx: 8, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` ↓ hash3r ◯ │ commit3 ↑ hash1l ◯ commit1 ↑ hash2l ⏣─╮ commit2 ↑ hash3l ◯ │ commit3 ↑ hash4l ◯─╯ commit4 ↑ hash5l ◯ commit5 `), }, { testName: "graph in divergence view - not all local commits", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1r", Parents: []string{"hash2r"}, Divergence: models.DivergenceRight}, {Name: "commit2", Hash: "hash2r", Parents: []string{"hash3r", "hash5r"}, Divergence: models.DivergenceRight}, {Name: "commit3", Hash: "hash3r", Parents: []string{"hash4r"}, Divergence: models.DivergenceRight}, {Name: "commit1", Hash: "hash1l", Parents: []string{"hash2l"}, Divergence: models.DivergenceLeft}, {Name: "commit2", Hash: "hash2l", Parents: []string{"hash3l", "hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit3", Hash: "hash3l", Parents: []string{"hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit4", Hash: "hash4l", Parents: []string{"hash5l"}, Divergence: models.DivergenceLeft}, {Name: "commit5", Hash: "hash5l", Parents: []string{"hash6l"}, Divergence: models.DivergenceLeft}, }, startIdx: 0, endIdx: 5, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` ↓ hash1r ◯ commit1 ↓ hash2r ⏣─╮ commit2 ↓ hash3r ◯ │ commit3 ↑ hash1l ◯ commit1 ↑ hash2l ⏣─╮ commit2 `), }, { testName: "graph in divergence view - no remote commits visible", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1r", Parents: []string{"hash2r"}, Divergence: models.DivergenceRight}, {Name: "commit2", Hash: "hash2r", Parents: []string{"hash3r", "hash5r"}, Divergence: models.DivergenceRight}, {Name: "commit3", Hash: "hash3r", Parents: []string{"hash4r"}, Divergence: models.DivergenceRight}, {Name: "commit1", Hash: "hash1l", Parents: []string{"hash2l"}, Divergence: models.DivergenceLeft}, {Name: "commit2", Hash: "hash2l", Parents: []string{"hash3l", "hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit3", Hash: "hash3l", Parents: []string{"hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit4", Hash: "hash4l", Parents: []string{"hash5l"}, Divergence: models.DivergenceLeft}, {Name: "commit5", Hash: "hash5l", Parents: []string{"hash6l"}, Divergence: models.DivergenceLeft}, }, startIdx: 4, endIdx: 8, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` ↑ hash2l ⏣─╮ commit2 ↑ hash3l ◯ │ commit3 ↑ hash4l ◯─╯ commit4 ↑ hash5l ◯ commit5 `), }, { testName: "graph in divergence view - no local commits visible", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1r", Parents: []string{"hash2r"}, Divergence: models.DivergenceRight}, {Name: "commit2", Hash: "hash2r", Parents: []string{"hash3r", "hash5r"}, Divergence: models.DivergenceRight}, {Name: "commit3", Hash: "hash3r", Parents: []string{"hash4r"}, Divergence: models.DivergenceRight}, {Name: "commit1", Hash: "hash1l", Parents: []string{"hash2l"}, Divergence: models.DivergenceLeft}, {Name: "commit2", Hash: "hash2l", Parents: []string{"hash3l", "hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit3", Hash: "hash3l", Parents: []string{"hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit4", Hash: "hash4l", Parents: []string{"hash5l"}, Divergence: models.DivergenceLeft}, {Name: "commit5", Hash: "hash5l", Parents: []string{"hash6l"}, Divergence: models.DivergenceLeft}, }, startIdx: 0, endIdx: 2, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` ↓ hash1r ◯ commit1 ↓ hash2r ⏣─╮ commit2 `), }, { testName: "graph in divergence view - no remote commits present", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1l", Parents: []string{"hash2l"}, Divergence: models.DivergenceLeft}, {Name: "commit2", Hash: "hash2l", Parents: []string{"hash3l", "hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit3", Hash: "hash3l", Parents: []string{"hash4l"}, Divergence: models.DivergenceLeft}, {Name: "commit4", Hash: "hash4l", Parents: []string{"hash5l"}, Divergence: models.DivergenceLeft}, {Name: "commit5", Hash: "hash5l", Parents: []string{"hash6l"}, Divergence: models.DivergenceLeft}, }, startIdx: 0, endIdx: 5, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` ↑ hash1l ◯ commit1 ↑ hash2l ⏣─╮ commit2 ↑ hash3l ◯ │ commit3 ↑ hash4l ◯─╯ commit4 ↑ hash5l ◯ commit5 `), }, { testName: "graph in divergence view - no local commits present", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1r", Parents: []string{"hash2r"}, Divergence: models.DivergenceRight}, {Name: "commit2", Hash: "hash2r", Parents: []string{"hash3r", "hash5r"}, Divergence: models.DivergenceRight}, {Name: "commit3", Hash: "hash3r", Parents: []string{"hash4r"}, Divergence: models.DivergenceRight}, }, startIdx: 0, endIdx: 3, showGraph: true, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), expected: formatExpected(` ↓ hash1r ◯ commit1 ↓ hash2r ⏣─╮ commit2 ↓ hash3r ◯ │ commit3 `), }, { testName: "custom time format", commitOpts: []models.NewCommitOpts{ {Name: "commit1", Hash: "hash1", UnixTimestamp: 1577844184, AuthorName: "Jesse Duffield"}, {Name: "commit2", Hash: "hash2", UnixTimestamp: 1576844184, AuthorName: "Jesse Duffield"}, }, fullDescription: true, timeFormat: "2006-01-02", shortTimeFormat: "3:04PM", startIdx: 0, endIdx: 2, showGraph: false, bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 5, 3, 4, 0, time.UTC), expected: formatExpected(` hash1 2:03AM Jesse Duffield commit1 hash2 2019-12-20 Jesse Duffield commit2 `), }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) defer color.ForceSetColorLevel(oldColorLevel) os.Setenv("TZ", "UTC") focusing := false for _, scenario := range scenarios { if scenario.focus { focusing = true } } common := utils.NewDummyCommon() for _, s := range scenarios { if !focusing || s.focus { t.Run(s.testName, func(t *testing.T) { hashPool := &utils.StringPool{} commits := lo.Map(s.commitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) result := GetCommitListDisplayStrings( common, commits, s.branches, s.currentBranchName, s.hasUpdateRefConfig, s.fullDescription, s.cherryPickedCommitHashSet, s.diffName, s.markedBaseCommit, s.timeFormat, s.shortTimeFormat, s.now, s.parseEmoji, s.selectedCommitHashPtr, s.startIdx, s.endIdx, s.showGraph, s.bisectInfo, ) renderedLines, _ := utils.RenderDisplayStrings(result, nil) renderedResult := strings.Join(renderedLines, "\n") t.Logf("\n%s", renderedResult) assert.EqualValues(t, s.expected, renderedResult) }) } } } lazygit-0.50.0+ds1/pkg/gui/presentation/files.go000066400000000000000000000223001500612110400214360ustar00rootroot00000000000000package presentation import ( "strings" "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" ) const ( EXPANDED_ARROW = "▼" COLLAPSED_ARROW = "▶" ) func RenderFileTree( tree filetree.IFileTree, submoduleConfigs []*models.SubmoduleConfig, showFileIcons bool, showNumstat bool, customIconsConfig *config.CustomIconsConfig, ) []string { collapsedPaths := tree.CollapsedPaths() return renderAux(tree.GetRoot().Raw(), collapsedPaths, -1, -1, func(node *filetree.Node[models.File], treeDepth int, visualDepth int, isCollapsed bool) string { fileNode := filetree.NewFileNode(node) return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig) }) } func RenderCommitFileTree( tree *filetree.CommitFileTreeViewModel, patchBuilder *patch.PatchBuilder, showFileIcons bool, customIconsConfig *config.CustomIconsConfig, ) []string { collapsedPaths := tree.CollapsedPaths() return renderAux(tree.GetRoot().Raw(), collapsedPaths, -1, -1, func(node *filetree.Node[models.CommitFile], treeDepth int, visualDepth int, isCollapsed bool) string { status := commitFilePatchStatus(node, tree, patchBuilder) return getCommitFileLine(isCollapsed, treeDepth, visualDepth, node, status, showFileIcons, customIconsConfig) }) } // Returns the status of a commit file in terms of its inclusion in the custom patch func commitFilePatchStatus(node *filetree.Node[models.CommitFile], tree *filetree.CommitFileTreeViewModel, patchBuilder *patch.PatchBuilder) patch.PatchStatus { // This is a little convoluted because we're dealing with either a leaf or a non-leaf. // But this code actually applies to both. If it's a leaf, the status will just // be whatever status it is, but if it's a non-leaf it will determine its status // based on the leaves of that subtree if node.EveryFile(func(file *models.CommitFile) bool { return patchBuilder.GetFileStatus(file.Path, tree.GetRef().RefName()) == patch.WHOLE }) { return patch.WHOLE } else if node.EveryFile(func(file *models.CommitFile) bool { return patchBuilder.GetFileStatus(file.Path, tree.GetRef().RefName()) == patch.UNSELECTED }) { return patch.UNSELECTED } else { return patch.PART } } func renderAux[T any]( node *filetree.Node[T], collapsedPaths *filetree.CollapsedPaths, // treeDepth is the depth of the node in the actual file tree. This is different to // visualDepth because some directory nodes are compressed e.g. 'pkg/gui/blah' takes // up two tree depths, but one visual depth. We need to track these separately, // because indentation relies on visual depth, whereas file path truncation // relies on tree depth. treeDepth int, visualDepth int, renderLine func(*filetree.Node[T], int, int, bool) string, ) []string { if node == nil { return []string{} } isRoot := treeDepth == -1 if node.IsFile() { if isRoot { return []string{} } return []string{renderLine(node, treeDepth, visualDepth, false)} } arr := []string{} if !isRoot { isCollapsed := collapsedPaths.IsCollapsed(node.GetInternalPath()) arr = append(arr, renderLine(node, treeDepth, visualDepth, isCollapsed)) } if collapsedPaths.IsCollapsed(node.GetInternalPath()) { return arr } for _, child := range node.Children { arr = append(arr, renderAux(child, collapsedPaths, treeDepth+1+node.CompressionLevel, visualDepth+1, renderLine)...) } return arr } func getFileLine( isCollapsed bool, hasUnstagedChanges bool, hasStagedChanges bool, treeDepth int, visualDepth int, showNumstat, showFileIcons bool, submoduleConfigs []*models.SubmoduleConfig, node *filetree.Node[models.File], customIconsConfig *config.CustomIconsConfig, ) string { name := fileNameAtDepth(node, treeDepth) output := "" var nameColor style.TextStyle file := node.File indentation := strings.Repeat(" ", visualDepth) if hasStagedChanges && !hasUnstagedChanges { nameColor = style.FgGreen } else if hasStagedChanges { nameColor = style.FgYellow } else { nameColor = theme.DefaultTextColor } if file == nil { output += indentation + "" arrow := EXPANDED_ARROW if isCollapsed { arrow = COLLAPSED_ARROW } arrowStyle := nameColor output += arrowStyle.Sprint(arrow) + " " } else { // Sprinting the space at the end in the specific style is for the sake of // when a reverse style is used in the theme, which looks ugly if you just // use the default style output += indentation + formatFileStatus(file, nameColor) + nameColor.Sprint(" ") } isSubmodule := file != nil && file.IsSubmodule(submoduleConfigs) isLinkedWorktree := file != nil && file.IsWorktree isDirectory := file == nil if showFileIcons { icon := icons.IconForFile(name, isSubmodule, isLinkedWorktree, isDirectory, customIconsConfig) paint := color.HEX(icon.Color, false) output += paint.Sprint(icon.Icon) + nameColor.Sprint(" ") } output += nameColor.Sprint(utils.EscapeSpecialChars(name)) if isSubmodule { output += theme.DefaultTextColor.Sprint(" (submodule)") } if file != nil && showNumstat { if lineChanges := formatLineChanges(file.LinesAdded, file.LinesDeleted); lineChanges != "" { output += " " + lineChanges } } return output } func formatFileStatus(file *models.File, restColor style.TextStyle) string { firstChar := file.ShortStatus[0:1] firstCharCl := style.FgGreen if firstChar == "?" { firstCharCl = theme.UnstagedChangesColor } else if firstChar == " " { firstCharCl = restColor } secondChar := file.ShortStatus[1:2] secondCharCl := theme.UnstagedChangesColor if secondChar == " " { secondCharCl = restColor } return firstCharCl.Sprint(firstChar) + secondCharCl.Sprint(secondChar) } func formatLineChanges(linesAdded, linesDeleted int) string { output := "" if linesAdded != 0 { output += style.FgGreen.Sprintf("+%d", linesAdded) } if linesDeleted != 0 { if output != "" { output += " " } output += style.FgRed.Sprintf("-%d", linesDeleted) } return output } func getCommitFileLine( isCollapsed bool, treeDepth int, visualDepth int, node *filetree.Node[models.CommitFile], status patch.PatchStatus, showFileIcons bool, customIconsConfig *config.CustomIconsConfig, ) string { indentation := strings.Repeat(" ", visualDepth) name := commitFileNameAtDepth(node, treeDepth) commitFile := node.File output := indentation isDirectory := commitFile == nil nameColor := theme.DefaultTextColor switch status { case patch.WHOLE: nameColor = style.FgGreen case patch.PART: nameColor = style.FgYellow case patch.UNSELECTED: nameColor = theme.DefaultTextColor } if isDirectory { arrow := EXPANDED_ARROW if isCollapsed { arrow = COLLAPSED_ARROW } output += nameColor.Sprint(arrow) + " " } else { var symbol string symbolStyle := nameColor switch status { case patch.WHOLE: symbol = "●" case patch.PART: symbol = "◐" case patch.UNSELECTED: symbol = commitFile.ChangeStatus symbolStyle = getColorForChangeStatus(symbol) } output += symbolStyle.Sprint(symbol) + " " } name = utils.EscapeSpecialChars(name) isSubmodule := false isLinkedWorktree := false if showFileIcons { icon := icons.IconForFile(name, isSubmodule, isLinkedWorktree, isDirectory, customIconsConfig) paint := color.HEX(icon.Color, false) output += paint.Sprint(icon.Icon) + " " } output += nameColor.Sprint(name) return output } func getColorForChangeStatus(changeStatus string) style.TextStyle { switch changeStatus { case "A": return style.FgGreen case "M", "R": return style.FgYellow case "D": return theme.UnstagedChangesColor case "C": return style.FgCyan case "T": return style.FgMagenta default: return theme.DefaultTextColor } } func fileNameAtDepth(node *filetree.Node[models.File], depth int) string { splitName := split(node.GetInternalPath()) if depth == 0 && splitName[0] == "." { if len(splitName) == 1 { return "/" } depth = 1 } name := join(splitName[depth:]) if node.File != nil && node.File.IsRename() { splitPrevName := split("./" + node.File.PreviousPath) prevName := node.File.PreviousPath // if the file has just been renamed inside the same directory, we can shave off // the prefix for the previous path too. Otherwise we'll keep it unchanged sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth]) if sameParentDir { prevName = join(splitPrevName[depth:]) } return prevName + " → " + name } return name } func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) string { splitName := split(node.GetInternalPath()) if depth == 0 && splitName[0] == "." { if len(splitName) == 1 { return "/" } depth = 1 } name := join(splitName[depth:]) return name } func split(str string) []string { return strings.Split(str, "/") } func join(strs []string) string { return strings.Join(strs, "/") } lazygit-0.50.0+ds1/pkg/gui/presentation/files_test.go000066400000000000000000000110731500612110400225020ustar00rootroot00000000000000package presentation import ( "strings" "testing" "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" "github.com/xo/terminfo" ) func toStringSlice(str string) []string { return strings.Split(strings.TrimSpace(str), "\n") } func TestRenderFileTree(t *testing.T) { scenarios := []struct { name string root *filetree.FileNode files []*models.File collapsedPaths []string showLineChanges bool expected []string }{ { name: "nil node", files: nil, expected: []string{}, }, { name: "leaf node", files: []*models.File{ {Path: "test", ShortStatus: " M", HasStagedChanges: true}, }, expected: []string{" M test"}, }, { name: "numstat", files: []*models.File{ {Path: "test", ShortStatus: " M", HasStagedChanges: true, LinesAdded: 1, LinesDeleted: 1}, {Path: "test2", ShortStatus: " M", HasStagedChanges: true, LinesAdded: 1}, {Path: "test3", ShortStatus: " M", HasStagedChanges: true, LinesDeleted: 1}, {Path: "test4", ShortStatus: " M", HasStagedChanges: true, LinesAdded: 0, LinesDeleted: 0}, }, showLineChanges: true, expected: []string{ "▼ /", " M test +1 -1", " M test2 +1", " M test3 -1", " M test4", }, }, { name: "big example", files: []*models.File{ {Path: "dir1/file2", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "dir1/file3", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "dir2/dir2/file3", ShortStatus: " M", HasStagedChanges: true}, {Path: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true}, {Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true}, }, expected: toStringSlice( ` ▼ / ▶ dir1 ▼ dir2 ▼ dir2 M file3 M file4 M file5 M file1 `, ), collapsedPaths: []string{"./dir1"}, }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) defer color.ForceSetColorLevel(oldColorLevel) for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { viewModel := filetree.NewFileTree(func() []*models.File { return s.files }, utils.NewDummyLog(), true) viewModel.SetTree() for _, path := range s.collapsedPaths { viewModel.ToggleCollapsed(path) } result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{}) assert.EqualValues(t, s.expected, result) }) } } func TestRenderCommitFileTree(t *testing.T) { scenarios := []struct { name string root *filetree.FileNode files []*models.CommitFile collapsedPaths []string expected []string }{ { name: "nil node", files: nil, expected: []string{}, }, { name: "leaf node", files: []*models.CommitFile{ {Path: "test", ChangeStatus: "A"}, }, expected: []string{"A test"}, }, { name: "big example", files: []*models.CommitFile{ {Path: "dir1/file2", ChangeStatus: "M"}, {Path: "dir1/file3", ChangeStatus: "A"}, {Path: "dir2/dir2/file3", ChangeStatus: "D"}, {Path: "dir2/dir2/file4", ChangeStatus: "M"}, {Path: "dir2/file5", ChangeStatus: "M"}, {Path: "file1", ChangeStatus: "M"}, }, expected: toStringSlice( ` ▼ / ▶ dir1 ▼ dir2 ▼ dir2 D file3 M file4 M file5 M file1 `, ), collapsedPaths: []string{"./dir1"}, }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) defer color.ForceSetColorLevel(oldColorLevel) for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { hashPool := &utils.StringPool{} viewModel := filetree.NewCommitFileTreeViewModel(func() []*models.CommitFile { return s.files }, utils.NewDummyLog(), true) viewModel.SetRef(models.NewCommit(hashPool, models.NewCommitOpts{Hash: "1234"})) viewModel.SetTree() for _, path := range s.collapsedPaths { viewModel.ToggleCollapsed(path) } patchBuilder := patch.NewPatchBuilder( utils.NewDummyLog(), func(from string, to string, reverse bool, filename string, plain bool) (string, error) { return "", nil }, ) patchBuilder.Start("from", "to", false, false) result := RenderCommitFileTree(viewModel, patchBuilder, false, &config.CustomIconsConfig{}) assert.EqualValues(t, s.expected, result) }) } } lazygit-0.50.0+ds1/pkg/gui/presentation/graph/000077500000000000000000000000001500612110400211115ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/presentation/graph/cell.go000066400000000000000000000077211500612110400223660ustar00rootroot00000000000000package graph import ( "io" "sync" "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/gui/style" ) const ( MergeSymbol = '⏣' CommitSymbol = '◯' ) type cellType int const ( CONNECTION cellType = iota COMMIT MERGE ) type Cell struct { up, down, left, right bool cellType cellType rightStyle *style.TextStyle style *style.TextStyle } func (cell *Cell) render(writer io.StringWriter) { up, down, left, right := cell.up, cell.down, cell.left, cell.right first, second := getBoxDrawingChars(up, down, left, right) var adjustedFirst string switch cell.cellType { case CONNECTION: adjustedFirst = first case COMMIT: adjustedFirst = string(CommitSymbol) case MERGE: adjustedFirst = string(MergeSymbol) } var rightStyle *style.TextStyle if cell.rightStyle == nil { rightStyle = cell.style } else { rightStyle = cell.rightStyle } // just doing this for the sake of easy testing, so that we don't need to // assert on the style of a space given a space has no styling (assuming we // stick to only using foreground styles) var styledSecondChar string if second == " " { styledSecondChar = " " } else { styledSecondChar = cachedSprint(*rightStyle, second) } _, _ = writer.WriteString(cachedSprint(*cell.style, adjustedFirst)) _, _ = writer.WriteString(styledSecondChar) } type rgbCacheKey struct { *color.RGBStyle str string } var ( rgbCache = make(map[rgbCacheKey]string) rgbCacheMutex sync.RWMutex ) func cachedSprint(style style.TextStyle, str string) string { switch v := style.Style.(type) { case *color.RGBStyle: rgbCacheMutex.RLock() key := rgbCacheKey{v, str} value, ok := rgbCache[key] rgbCacheMutex.RUnlock() if ok { return value } value = style.Sprint(str) rgbCacheMutex.Lock() rgbCache[key] = value rgbCacheMutex.Unlock() return value case color.Basic: return style.Sprint(str) case color.Style: value := style.Sprint(str) return value } return style.Sprint(str) } func (cell *Cell) reset() { cell.up = false cell.down = false cell.left = false cell.right = false } func (cell *Cell) setUp(style *style.TextStyle) *Cell { cell.up = true cell.style = style return cell } func (cell *Cell) setDown(style *style.TextStyle) *Cell { cell.down = true cell.style = style return cell } func (cell *Cell) setLeft(style *style.TextStyle) *Cell { cell.left = true if !cell.up && !cell.down { // vertical trumps left cell.style = style } return cell } //nolint:unparam func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell { cell.right = true if cell.rightStyle == nil || override { cell.rightStyle = style } return cell } func (cell *Cell) setStyle(style *style.TextStyle) *Cell { cell.style = style return cell } func (cell *Cell) setType(cellType cellType) *Cell { cell.cellType = cellType return cell } func getBoxDrawingChars(up, down, left, right bool) (string, string) { if up && down && left && right { return "│", "─" } else if up && down && left && !right { return "│", " " } else if up && down && !left && right { return "│", "─" } else if up && down && !left && !right { return "│", " " } else if up && !down && left && right { return "┴", "─" } else if up && !down && left && !right { return "╯", " " } else if up && !down && !left && right { return "╰", "─" } else if up && !down && !left && !right { return "╵", " " } else if !up && down && left && right { return "┬", "─" } else if !up && down && left && !right { return "╮", " " } else if !up && down && !left && right { return "╭", "─" } else if !up && down && !left && !right { return "╷", " " } else if !up && !down && left && right { return "─", "─" } else if !up && !down && left && !right { return "─", " " } else if !up && !down && !left && right { return "╶", "─" } else if !up && !down && !left && !right { return " ", " " } else { panic("should not be possible") } } lazygit-0.50.0+ds1/pkg/gui/presentation/graph/graph.go000066400000000000000000000235151500612110400225470ustar00rootroot00000000000000package graph import ( "cmp" "runtime" "slices" "strings" "sync" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type PipeKind uint8 const ( TERMINATES PipeKind = iota STARTS CONTINUES ) type Pipe struct { fromHash *string toHash *string style *style.TextStyle fromPos int16 toPos int16 kind PipeKind } var ( highlightStyle = style.FgLightWhite.SetBold() EmptyTreeCommitHash = models.EmptyTreeCommitHash StartCommitHash = "START" ) func (self Pipe) left() int16 { return min(self.fromPos, self.toPos) } func (self Pipe) right() int16 { return max(self.fromPos, self.toPos) } func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle) []string { pipeSets := GetPipeSets(commits, getStyle) if len(pipeSets) == 0 { return nil } lines := RenderAux(pipeSets, commits, selectedCommitHashPtr) return lines } func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) *style.TextStyle) [][]Pipe { if len(commits) == 0 { return nil } pipes := []Pipe{{fromPos: 0, toPos: 0, fromHash: &StartCommitHash, toHash: commits[0].HashPtr(), kind: STARTS, style: &style.FgDefault}} return lo.Map(commits, func(commit *models.Commit, _ int) []Pipe { pipes = getNextPipes(pipes, commit, getStyle) return pipes }) } func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string) []string { maxProcs := runtime.GOMAXPROCS(0) // splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel chunks := make([][]string, maxProcs) perProc := len(pipeSets) / maxProcs wg := sync.WaitGroup{} wg.Add(maxProcs) for i := 0; i < maxProcs; i++ { go func() { from := i * perProc to := (i + 1) * perProc if i == maxProcs-1 { to = len(pipeSets) } innerLines := make([]string, 0, to-from) for j, pipeSet := range pipeSets[from:to] { k := from + j var prevCommit *models.Commit if k > 0 { prevCommit = commits[k-1] } line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit) innerLines = append(innerLines, line) } chunks[i] = innerLines wg.Done() }() } wg.Wait() return lo.Flatten(chunks) } func getNextPipes(prevPipes []Pipe, commit *models.Commit, getStyle func(c *models.Commit) *style.TextStyle) []Pipe { maxPos := int16(0) for _, pipe := range prevPipes { if pipe.toPos > maxPos { maxPos = pipe.toPos } } // a pipe that terminated in the previous line has no bearing on the current line // so we'll filter those out currentPipes := lo.Filter(prevPipes, func(pipe Pipe, _ int) bool { return pipe.kind != TERMINATES }) newPipes := make([]Pipe, 0, len(currentPipes)+len(commit.ParentPtrs())) // start by assuming that we've got a brand new commit not related to any preceding commit. // (this only happens when we're doing `git log --all`). These will be tacked onto the far end. pos := maxPos + 1 for _, pipe := range currentPipes { if equalHashes(pipe.toHash, commit.HashPtr()) { // turns out this commit does have a descendant so we'll place it right under the first instance pos = pipe.toPos break } } // a taken spot is one where a current pipe is ending on // Note: this set and similar ones below use int instead of int16 because // that's much more efficient. We cast the int16 values we store in these // sets to int on every access. takenSpots := set.New[int]() // a traversed spot is one where a current pipe is starting on, ending on, or passing through traversedSpots := set.New[int]() var toHash *string if commit.IsFirstCommit() { toHash = &EmptyTreeCommitHash } else { toHash = commit.ParentPtrs()[0] } newPipes = append(newPipes, Pipe{ fromPos: pos, toPos: pos, fromHash: commit.HashPtr(), toHash: toHash, kind: STARTS, style: getStyle(commit), }) traversedSpotsForContinuingPipes := set.New[int]() for _, pipe := range currentPipes { if !equalHashes(pipe.toHash, commit.HashPtr()) { traversedSpotsForContinuingPipes.Add(int(pipe.toPos)) } } getNextAvailablePosForContinuingPipe := func() int16 { i := int16(0) for { if !traversedSpots.Includes(int(i)) { return i } i++ } } getNextAvailablePosForNewPipe := func() int16 { i := int16(0) for { // a newly created pipe is not allowed to end on a spot that's already taken, // nor on a spot that's been traversed by a continuing pipe. if !takenSpots.Includes(int(i)) && !traversedSpotsForContinuingPipes.Includes(int(i)) { return i } i++ } } traverse := func(from, to int16) { left, right := from, to if left > right { left, right = right, left } for i := left; i <= right; i++ { traversedSpots.Add(int(i)) } takenSpots.Add(int(to)) } for _, pipe := range currentPipes { if equalHashes(pipe.toHash, commit.HashPtr()) { // terminating here newPipes = append(newPipes, Pipe{ fromPos: pipe.toPos, toPos: pos, fromHash: pipe.fromHash, toHash: pipe.toHash, kind: TERMINATES, style: pipe.style, }) traverse(pipe.toPos, pos) } else if pipe.toPos < pos { // continuing here availablePos := getNextAvailablePosForContinuingPipe() newPipes = append(newPipes, Pipe{ fromPos: pipe.toPos, toPos: availablePos, fromHash: pipe.fromHash, toHash: pipe.toHash, kind: CONTINUES, style: pipe.style, }) traverse(pipe.toPos, availablePos) } } if commit.IsMerge() { for _, parent := range commit.ParentPtrs()[1:] { availablePos := getNextAvailablePosForNewPipe() // need to act as if continuing pipes are going to continue on the same line. newPipes = append(newPipes, Pipe{ fromPos: pos, toPos: availablePos, fromHash: commit.HashPtr(), toHash: parent, kind: STARTS, style: getStyle(commit), }) takenSpots.Add(int(availablePos)) } } for _, pipe := range currentPipes { if !equalHashes(pipe.toHash, commit.HashPtr()) && pipe.toPos > pos { // continuing on, potentially moving left to fill in a blank spot last := pipe.toPos for i := pipe.toPos; i > pos; i-- { if takenSpots.Includes(int(i)) || traversedSpots.Includes(int(i)) { break } else { last = i } } newPipes = append(newPipes, Pipe{ fromPos: pipe.toPos, toPos: last, fromHash: pipe.fromHash, toHash: pipe.toHash, kind: CONTINUES, style: pipe.style, }) traverse(pipe.toPos, last) } } // not efficient but doing it for now: sorting my pipes by toPos, then by kind slices.SortFunc(newPipes, func(a, b Pipe) int { if a.toPos == b.toPos { return cmp.Compare(a.kind, b.kind) } return cmp.Compare(a.toPos, b.toPos) }) return newPipes } func renderPipeSet( pipes []Pipe, selectedCommitHashPtr *string, prevCommit *models.Commit, ) string { maxPos := int16(0) commitPos := int16(0) startCount := 0 for _, pipe := range pipes { if pipe.kind == STARTS { startCount++ commitPos = pipe.fromPos } else if pipe.kind == TERMINATES { commitPos = pipe.toPos } if pipe.right() > maxPos { maxPos = pipe.right() } } isMerge := startCount > 1 cells := lo.Map(lo.Range(int(maxPos)+1), func(i int, _ int) *Cell { return &Cell{cellType: CONNECTION, style: &style.FgDefault} }) renderPipe := func(pipe *Pipe, style *style.TextStyle, overrideRightStyle bool) { left := pipe.left() right := pipe.right() if left != right { for i := left + 1; i < right; i++ { cells[i].setLeft(style).setRight(style, overrideRightStyle) } cells[left].setRight(style, overrideRightStyle) cells[right].setLeft(style) } if pipe.kind == STARTS || pipe.kind == CONTINUES { cells[pipe.toPos].setDown(style) } if pipe.kind == TERMINATES || pipe.kind == CONTINUES { cells[pipe.fromPos].setUp(style) } } // we don't want to highlight two commits if they're contiguous. We only want // to highlight multiple things if there's an actual visible pipe involved. highlight := true if prevCommit != nil && equalHashes(prevCommit.HashPtr(), selectedCommitHashPtr) { highlight = false for _, pipe := range pipes { if equalHashes(pipe.fromHash, selectedCommitHashPtr) && (pipe.kind != TERMINATES || pipe.fromPos != pipe.toPos) { highlight = true } } } // so we have our commit pos again, now it's time to build the cells. // we'll handle the one that's sourced from our selected commit last so that it can override the other cells. selectedPipes, nonSelectedPipes := utils.Partition(pipes, func(pipe Pipe) bool { return highlight && equalHashes(pipe.fromHash, selectedCommitHashPtr) }) for _, pipe := range nonSelectedPipes { if pipe.kind == STARTS { renderPipe(&pipe, pipe.style, true) } } for _, pipe := range nonSelectedPipes { if pipe.kind != STARTS && !(pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos) { renderPipe(&pipe, pipe.style, false) } } for _, pipe := range selectedPipes { for i := pipe.left(); i <= pipe.right(); i++ { cells[i].reset() } } for _, pipe := range selectedPipes { renderPipe(&pipe, &highlightStyle, true) if pipe.toPos == commitPos { cells[pipe.toPos].setStyle(&highlightStyle) } } cType := COMMIT if isMerge { cType = MERGE } cells[commitPos].setType(cType) // using a string builder here for the sake of performance writer := &strings.Builder{} writer.Grow(len(cells) * 2) for _, cell := range cells { cell.render(writer) } return writer.String() } func equalHashes(a, b *string) bool { // if our selectedCommitHashPtr is nil, there is no selected commit if a == nil || b == nil { return false } // We know that all hashes are stored in the pool, so we can compare their addresses return a == b } lazygit-0.50.0+ds1/pkg/gui/presentation/graph/graph_test.go000066400000000000000000000537441500612110400236150ustar00rootroot00000000000000package graph import ( "fmt" "math/rand" "strings" "testing" "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation/authors" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/xo/terminfo" ) func TestRenderCommitGraph(t *testing.T) { tests := []struct { name string commitOpts []models.NewCommitOpts expectedOutput string }{ { name: "with some merges", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"3"}}, {Hash: "3", Parents: []string{"4"}}, {Hash: "4", Parents: []string{"5", "7"}}, {Hash: "7", Parents: []string{"5"}}, {Hash: "5", Parents: []string{"8"}}, {Hash: "8", Parents: []string{"9"}}, {Hash: "9", Parents: []string{"A", "B"}}, {Hash: "B", Parents: []string{"D"}}, {Hash: "D", Parents: []string{"D"}}, {Hash: "A", Parents: []string{"E"}}, {Hash: "E", Parents: []string{"F"}}, {Hash: "F", Parents: []string{"D"}}, {Hash: "D", Parents: []string{"G"}}, }, expectedOutput: ` 1 ◯ 2 ◯ 3 ◯ 4 ⏣─╮ 7 │ ◯ 5 ◯─╯ 8 ◯ 9 ⏣─╮ B │ ◯ D │ ◯ A ◯ │ E ◯ │ F ◯ │ D ◯─╯`, }, { name: "with a path that has room to move to the left", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"3", "4"}}, {Hash: "4", Parents: []string{"3", "5"}}, {Hash: "3", Parents: []string{"5"}}, {Hash: "5", Parents: []string{"6"}}, {Hash: "6", Parents: []string{"7"}}, }, expectedOutput: ` 1 ◯ 2 ⏣─╮ 4 │ ⏣─╮ 3 ◯─╯ │ 5 ◯───╯ 6 ◯`, }, { name: "with a new commit", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"3", "4"}}, {Hash: "4", Parents: []string{"3", "5"}}, {Hash: "Z", Parents: []string{"Z"}}, {Hash: "3", Parents: []string{"5"}}, {Hash: "5", Parents: []string{"6"}}, {Hash: "6", Parents: []string{"7"}}, }, expectedOutput: ` 1 ◯ 2 ⏣─╮ 4 │ ⏣─╮ Z │ │ │ ◯ 3 ◯─╯ │ │ 5 ◯───╯ │ 6 ◯ ╭───╯`, }, { name: "with a path that has room to move to the left and continues", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"3", "4"}}, {Hash: "3", Parents: []string{"5", "4"}}, {Hash: "5", Parents: []string{"7", "8"}}, {Hash: "4", Parents: []string{"7"}}, {Hash: "7", Parents: []string{"11"}}, }, expectedOutput: ` 1 ◯ 2 ⏣─╮ 3 ⏣─│─╮ 5 ⏣─│─│─╮ 4 │ ◯─╯ │ 7 ◯─╯ ╭─╯`, }, { name: "with a path that has room to move to the left and continues", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"3", "4"}}, {Hash: "3", Parents: []string{"5", "4"}}, {Hash: "5", Parents: []string{"7", "8"}}, {Hash: "7", Parents: []string{"4", "A"}}, {Hash: "4", Parents: []string{"B"}}, {Hash: "B", Parents: []string{"C"}}, }, expectedOutput: ` 1 ◯ 2 ⏣─╮ 3 ⏣─│─╮ 5 ⏣─│─│─╮ 7 ⏣─│─│─│─╮ 4 ◯─┴─╯ │ │ B ◯ ╭───╯ │`, }, { name: "with a path that has room to move to the left and continues", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2", "3"}}, {Hash: "3", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"4", "5"}}, {Hash: "4", Parents: []string{"6", "7"}}, {Hash: "6", Parents: []string{"8"}}, }, expectedOutput: ` 1 ⏣─╮ 3 │ ◯ 2 ⏣─│ 4 ⏣─│─╮ 6 ◯ │ │`, }, { name: "new merge path fills gap before continuing path on right", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2", "3", "4", "5"}}, {Hash: "4", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"A"}}, {Hash: "A", Parents: []string{"6", "B"}}, {Hash: "B", Parents: []string{"C"}}, }, expectedOutput: ` 1 ⏣─┬─┬─╮ 4 │ │ ◯ │ 2 ◯─│─╯ │ A ⏣─│─╮ │ B │ │ ◯ │`, }, { name: "with a path that has room to move to the left and continues", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"3", "4"}}, {Hash: "3", Parents: []string{"5", "4"}}, {Hash: "5", Parents: []string{"7", "8"}}, {Hash: "7", Parents: []string{"4", "A"}}, {Hash: "4", Parents: []string{"B"}}, {Hash: "B", Parents: []string{"C"}}, {Hash: "C", Parents: []string{"D"}}, }, expectedOutput: ` 1 ◯ 2 ⏣─╮ 3 ⏣─│─╮ 5 ⏣─│─│─╮ 7 ⏣─│─│─│─╮ 4 ◯─┴─╯ │ │ B ◯ ╭───╯ │ C ◯ │ ╭───╯`, }, { name: "with a path that has room to move to the left and continues", commitOpts: []models.NewCommitOpts{ {Hash: "1", Parents: []string{"2"}}, {Hash: "2", Parents: []string{"3", "4"}}, {Hash: "3", Parents: []string{"5", "4"}}, {Hash: "5", Parents: []string{"7", "G"}}, {Hash: "7", Parents: []string{"8", "A"}}, {Hash: "8", Parents: []string{"4", "E"}}, {Hash: "4", Parents: []string{"B"}}, {Hash: "B", Parents: []string{"C"}}, {Hash: "C", Parents: []string{"D"}}, {Hash: "D", Parents: []string{"F"}}, }, expectedOutput: ` 1 ◯ 2 ⏣─╮ 3 ⏣─│─╮ 5 ⏣─│─│─╮ 7 ⏣─│─│─│─╮ 8 ⏣─│─│─│─│─╮ 4 ◯─┴─╯ │ │ │ B ◯ ╭───╯ │ │ C ◯ │ ╭───╯ │ D ◯ │ │ ╭───╯`, }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) defer color.ForceSetColorLevel(oldColorLevel) for _, test := range tests { t.Run(test.name, func(t *testing.T) { hashPool := &utils.StringPool{} getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault } commits := lo.Map(test.commitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle) trimmedExpectedOutput := "" for _, line := range strings.Split(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") { trimmedExpectedOutput += strings.TrimSpace(line) + "\n" } t.Log("\nexpected: \n" + trimmedExpectedOutput) output := "" for i, line := range lines { description := test.commitOpts[i].Hash output += strings.TrimSpace(description+" "+utils.Decolorise(line)) + "\n" } t.Log("\nactual: \n" + output) assert.Equal(t, trimmedExpectedOutput, output) }) } } func TestRenderPipeSet(t *testing.T) { cyan := style.FgCyan red := style.FgRed green := style.FgGreen // blue := style.FgBlue yellow := style.FgYellow magenta := style.FgMagenta nothing := style.Nothing hashPool := &utils.StringPool{} pool := func(s string) *string { return hashPool.Add(s) } tests := []struct { name string pipes []Pipe commit *models.Commit prevCommit *models.Commit expectedStr string expectedStyles []style.TextStyle }{ { name: "single cell", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan}, {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), expectedStr: "◯", expectedStyles: []style.TextStyle{green}, }, { name: "single cell, selected", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan}, {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("c"), kind: STARTS, style: &green}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), expectedStr: "◯", expectedStyles: []style.TextStyle{highlightStyle}, }, { name: "terminating hook and starting hook, selected", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan}, {fromPos: 1, toPos: 0, fromHash: pool("c"), toHash: pool("selected"), kind: TERMINATES, style: &yellow}, {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("d"), kind: STARTS, style: &green}, {fromPos: 0, toPos: 1, fromHash: pool("selected"), toHash: pool("e"), kind: STARTS, style: &green}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), expectedStr: "⏣─╮", expectedStyles: []style.TextStyle{ highlightStyle, highlightStyle, highlightStyle, }, }, { name: "terminating hook and starting hook, prioritise the terminating one", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &red}, {fromPos: 1, toPos: 0, fromHash: pool("c"), toHash: pool("b"), kind: TERMINATES, style: &magenta}, {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("d"), kind: STARTS, style: &green}, {fromPos: 0, toPos: 1, fromHash: pool("b"), toHash: pool("e"), kind: STARTS, style: &green}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), expectedStr: "⏣─│", expectedStyles: []style.TextStyle{ green, green, magenta, }, }, { name: "starting and terminating pipe sharing some space", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &yellow}, {fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b2"), kind: CONTINUES, style: &magenta}, {fromPos: 3, toPos: 0, fromHash: pool("e1"), toHash: pool("a2"), kind: TERMINATES, style: &green}, {fromPos: 0, toPos: 2, fromHash: pool("a2"), toHash: pool("c3"), kind: STARTS, style: &yellow}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), expectedStr: "⏣─│─┬─╯", expectedStyles: []style.TextStyle{ yellow, yellow, magenta, yellow, yellow, green, green, }, }, { name: "starting and terminating pipe sharing some space, with selection", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("selected"), kind: TERMINATES, style: &red}, {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a3"), kind: STARTS, style: &yellow}, {fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b2"), kind: CONTINUES, style: &magenta}, {fromPos: 3, toPos: 0, fromHash: pool("e1"), toHash: pool("selected"), kind: TERMINATES, style: &green}, {fromPos: 0, toPos: 2, fromHash: pool("selected"), toHash: pool("c3"), kind: STARTS, style: &yellow}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), expectedStr: "⏣───╮ ╯", expectedStyles: []style.TextStyle{ highlightStyle, highlightStyle, highlightStyle, highlightStyle, highlightStyle, nothing, green, }, }, { name: "many terminating pipes", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &yellow}, {fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta}, {fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &green}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), expectedStr: "◯─┴─╯", expectedStyles: []style.TextStyle{ yellow, magenta, magenta, green, green, }, }, { name: "starting pipe passing through", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &yellow}, {fromPos: 0, toPos: 3, fromHash: pool("a2"), toHash: pool("d3"), kind: STARTS, style: &yellow}, {fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b3"), kind: CONTINUES, style: &magenta}, {fromPos: 2, toPos: 2, fromHash: pool("c1"), toHash: pool("c3"), kind: CONTINUES, style: &green}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), expectedStr: "⏣─│─│─╮", expectedStyles: []style.TextStyle{ yellow, yellow, magenta, yellow, green, yellow, yellow, }, }, { name: "starting and terminating path crossing continuing path", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &yellow}, {fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &yellow}, {fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("a2"), kind: CONTINUES, style: &green}, {fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), expectedStr: "⏣─│─╯", expectedStyles: []style.TextStyle{ yellow, yellow, green, magenta, magenta, }, }, { name: "another clash of starting and terminating paths", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &yellow}, {fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &yellow}, {fromPos: 2, toPos: 2, fromHash: pool("c1"), toHash: pool("c3"), kind: CONTINUES, style: &green}, {fromPos: 3, toPos: 0, fromHash: pool("d1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), expectedStr: "⏣─┬─│─╯", expectedStyles: []style.TextStyle{ yellow, yellow, yellow, magenta, green, magenta, magenta, }, }, { name: "commit whose previous commit is selected", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &yellow}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}), expectedStr: "◯", expectedStyles: []style.TextStyle{ yellow, }, }, { name: "commit whose previous commit is selected and is a merge commit", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 1, toPos: 1, fromHash: pool("selected"), toHash: pool("b3"), kind: CONTINUES, style: &red}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}), expectedStr: "◯ │", expectedStyles: []style.TextStyle{ highlightStyle, nothing, highlightStyle, }, }, { name: "commit whose previous commit is selected and is a merge commit, with continuing pipe inbetween", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 1, toPos: 1, fromHash: pool("z1"), toHash: pool("z3"), kind: CONTINUES, style: &green}, {fromPos: 2, toPos: 2, fromHash: pool("selected"), toHash: pool("b3"), kind: CONTINUES, style: &red}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}), expectedStr: "◯ │ │", expectedStyles: []style.TextStyle{ highlightStyle, nothing, green, nothing, highlightStyle, }, }, { name: "when previous commit is selected, not a merge commit, and spawns a continuing pipe", pipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green}, {fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green}, {fromPos: 1, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &yellow}, }, prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}), expectedStr: "⏣─╯", expectedStyles: []style.TextStyle{ highlightStyle, highlightStyle, highlightStyle, }, }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) defer color.ForceSetColorLevel(oldColorLevel) for _, test := range tests { t.Run(test.name, func(t *testing.T) { actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit) t.Log("actual cells:") t.Log(actualStr) expectedStr := "" if len([]rune(test.expectedStr)) != len(test.expectedStyles) { t.Fatalf("Error in test setup: you have %d characters in the expected output (%s) but have specified %d styles", len([]rune(test.expectedStr)), test.expectedStr, len(test.expectedStyles)) } for i, char := range []rune(test.expectedStr) { expectedStr += test.expectedStyles[i].Sprint(string(char)) } expectedStr += " " t.Log("expected cells:") t.Log(expectedStr) assert.Equal(t, expectedStr, actualStr) }) } } func TestGetNextPipes(t *testing.T) { hashPool := &utils.StringPool{} pool := func(s string) *string { return hashPool.Add(s) } tests := []struct { prevPipes []Pipe commit *models.Commit expected []Pipe }{ { prevPipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: STARTS, style: &style.FgDefault}, }, commit: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "b", Parents: []string{"c"}, }), expected: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &style.FgDefault}, {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &style.FgDefault}, }, }, { prevPipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &style.FgDefault}, {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &style.FgDefault}, {fromPos: 0, toPos: 1, fromHash: pool("b"), toHash: pool("d"), kind: STARTS, style: &style.FgDefault}, }, commit: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "d", Parents: []string{"e"}, }), expected: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: CONTINUES, style: &style.FgDefault}, {fromPos: 1, toPos: 1, fromHash: pool("b"), toHash: pool("d"), kind: TERMINATES, style: &style.FgDefault}, {fromPos: 1, toPos: 1, fromHash: pool("d"), toHash: pool("e"), kind: STARTS, style: &style.FgDefault}, }, }, { prevPipes: []Pipe{ {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("root"), kind: TERMINATES, style: &style.FgDefault}, }, commit: models.NewCommit(hashPool, models.NewCommitOpts{ Hash: "root", Parents: []string{}, }), expected: []Pipe{ {fromPos: 1, toPos: 1, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault}, }, }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) defer color.ForceSetColorLevel(oldColorLevel) for _, test := range tests { getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault } pipes := getNextPipes(test.prevPipes, test.commit, getStyle) // rendering cells so that it's easier to see what went wrong actualStr := renderPipeSet(pipes, pool("selected"), nil) expectedStr := renderPipeSet(test.expected, pool("selected"), nil) t.Log("expected cells:") t.Log(expectedStr) t.Log("actual cells:") t.Log(actualStr) assert.EqualValues(t, test.expected, pipes) } } func BenchmarkRenderCommitGraph(b *testing.B) { oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) defer color.ForceSetColorLevel(oldColorLevel) hashPool := &utils.StringPool{} commits := generateCommits(hashPool, 50) getStyle := func(commit *models.Commit) *style.TextStyle { return authors.AuthorStyle(commit.AuthorName) } b.ResetTimer() for b.Loop() { RenderCommitGraph(commits, hashPool.Add("selected"), getStyle) } } func generateCommits(hashPool *utils.StringPool, count int) []*models.Commit { rnd := rand.New(rand.NewSource(1234)) pool := []*models.Commit{models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a", AuthorName: "A"})} commits := make([]*models.Commit, 0, count) authorPool := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"} for len(commits) < count { currentCommitIdx := rnd.Intn(len(pool)) currentCommit := pool[currentCommitIdx] pool = append(pool[0:currentCommitIdx], pool[currentCommitIdx+1:]...) // I need to pick a random number of parents to add parentCount := rnd.Intn(2) + 1 parentHashes := currentCommit.Parents() for j := 0; j < parentCount; j++ { reuseParent := rnd.Intn(6) != 1 && j <= len(pool)-1 && j != 0 var newParent *models.Commit if reuseParent { newParent = pool[j] } else { newParent = models.NewCommit(hashPool, models.NewCommitOpts{ Hash: fmt.Sprintf("%s%d", currentCommit.Hash(), j), AuthorName: authorPool[rnd.Intn(len(authorPool))], }) pool = append(pool, newParent) } parentHashes = append(parentHashes, newParent.Hash()) } changedCommit := models.NewCommit(hashPool, models.NewCommitOpts{ Hash: currentCommit.Hash(), AuthorName: currentCommit.AuthorName, Parents: parentHashes, }) commits = append(commits, changedCommit) } return commits } lazygit-0.50.0+ds1/pkg/gui/presentation/icons/000077500000000000000000000000001500612110400211235ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/presentation/icons/file_icons.go000066400000000000000000001504551500612110400235760ustar00rootroot00000000000000package icons import ( "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/config" ) // NOTE: Visit next links for inspiration: // https://github.com/eza-community/eza/blob/main/src/output/icons.rs // https://github.com/nvim-tree/nvim-web-devicons/tree/master/lua/nvim-web-devicons/default var ( DEFAULT_FILE_ICON = IconProperties{Icon: "\uf15b", Color: "#878787"} //  DEFAULT_SUBMODULE_ICON = IconProperties{Icon: "\U000f02a2", Color: "#FF4F00"} // 󰊢 DEFAULT_DIRECTORY_ICON = IconProperties{Icon: "\uf07b", Color: "#878787"} //  ) // NOTE: The filename map is case sensitive. var nameIconMap = map[string]IconProperties{ ".atom": {Icon: "\ue764", Color: "#EED9B7"}, //  ".babelrc": {Icon: "\ue639", Color: "#FED836"}, //  ".bash_profile": {Icon: "\ue615", Color: "#89E051"}, //  ".bashprofile": {Icon: "\ue615", Color: "#89E051"}, //  ".bashrc": {Icon: "\ue795", Color: "#89E051"}, //  ".clang-format": {Icon: "\ue615", Color: "#86806D"}, //  ".clang-tidy": {Icon: "\ue615", Color: "#86806D"}, //  ".codespellrc": {Icon: "\U000f04c6", Color: "#35DA60"}, // 󰓆 ".condarc": {Icon: "\ue715", Color: "#43B02A"}, //  ".dockerignore": {Icon: "\U000f0868", Color: "#458EE6"}, // 󰡨 ".ds_store": {Icon: "\uf302", Color: "#78919C"}, //  ".editorconfig": {Icon: "\ue652", Color: "#FFFFFF"}, //  ".env": {Icon: "\U000f066a", Color: "#FBC02D"}, // 󰙪 ".eslintignore": {Icon: "\U000f0c7a", Color: "#3F52B5"}, // 󰱺 ".eslintrc": {Icon: "\U000f0c7a", Color: "#3F52B5"}, // 󰱺 ".git": {Icon: "\U000f02a2", Color: "#E64A19"}, // 󰊢 ".git-blame-ignore-revs": {Icon: "\U000f02a2", Color: "#E64A19"}, // 󰊢 ".gitattributes": {Icon: "\U000f02a2", Color: "#E64A19"}, // 󰊢 ".gitconfig": {Icon: "\U000f02a2", Color: "#E64A19"}, // 󰊢 ".github": {Icon: "\uf408", Color: "#333333"}, //  ".gitignore": {Icon: "\U000f02a2", Color: "#E64A19"}, // 󰊢 ".gitlab-ci.yml": {Icon: "\uf296", Color: "#F54D27"}, //  ".gitmodules": {Icon: "\U000f02a2", Color: "#E64A19"}, // 󰊢 ".gtkrc-2.0": {Icon: "\uf362", Color: "#FFFFFF"}, //  ".gvimrc": {Icon: "\ue62b", Color: "#019833"}, //  ".idea": {Icon: "\ue7b5", Color: "#626262"}, //  ".justfile": {Icon: "\uf0ad", Color: "#6D8086"}, //  ".luacheckrc": {Icon: "\ue615", Color: "#868F9D"}, //  ".luaurc": {Icon: "\ue615", Color: "#00A2FF"}, //  ".mailmap": {Icon: "\U000f01ee", Color: "#42A5F5"}, // 󰇮 ".nanorc": {Icon: "\ue838", Color: "#440077"}, //  ".npmignore": {Icon: "\ued0e", Color: "#CC3837"}, //  ".npmrc": {Icon: "\ued0e", Color: "#CC3837"}, //  ".nuxtrc": {Icon: "\U000f1106", Color: "#00C58E"}, // 󱄆 ".nvmrc": {Icon: "\ued0d", Color: "#4CAF51"}, //  ".pre-commit-config.yaml": {Icon: "\U000f06e2", Color: "#F8B424"}, // 󰛢 ".prettierignore": {Icon: "\ue6b4", Color: "#4285F4"}, //  ".prettierrc": {Icon: "\ue6b4", Color: "#4285F4"}, //  ".prettierrc.json": {Icon: "\ue6b4", Color: "#4285F4"}, //  ".prettierrc.json5": {Icon: "\ue6b4", Color: "#4285F4"}, //  ".prettierrc.toml": {Icon: "\ue6b4", Color: "#4285F4"}, //  ".prettierrc.yaml": {Icon: "\ue6b4", Color: "#4285F4"}, //  ".prettierrc.yml": {Icon: "\ue6b4", Color: "#4285F4"}, //  ".pylintrc": {Icon: "\ue615", Color: "#968F6D"}, //  ".rvm": {Icon: "\ue21e", Color: "#D70000"}, //  ".settings.json": {Icon: "\ue70c", Color: "#854CC7"}, //  ".SRCINFO": {Icon: "\uf129", Color: "#0F94D2"}, //  ".tmux.conf": {Icon: "\uebc8", Color: "#14BA19"}, //  ".tmux.conf.local": {Icon: "\uebc8", Color: "#14BA19"}, //  ".Trash": {Icon: "\uf1f8", Color: "#ACBCEF"}, //  ".vimrc": {Icon: "\ue62b", Color: "#019833"}, //  ".vscode": {Icon: "\ue70c", Color: "#007ACC"}, //  ".Xauthority": {Icon: "\uf369", Color: "#E54D18"}, //  ".Xresources": {Icon: "\uf369", Color: "#E54D18"}, //  ".xinitrc": {Icon: "\uf369", Color: "#E54D18"}, //  ".xsession": {Icon: "\uf369", Color: "#E54D18"}, //  ".zprofile": {Icon: "\ue615", Color: "#89E051"}, //  ".zshenv": {Icon: "\ue615", Color: "#89E051"}, //  ".zshrc": {Icon: "\ue795", Color: "#89E051"}, //  "_gvimrc": {Icon: "\ue62b", Color: "#019833"}, //  "_vimrc": {Icon: "\ue62b", Color: "#019833"}, //  "AUTHORS": {Icon: "\uedca", Color: "#A172FF"}, //  "AUTHORS.txt": {Icon: "\uedca", Color: "#A172FF"}, //  "bin": {Icon: "\U000f12a7", Color: "#25A79A"}, // 󱊧 "brewfile": {Icon: "\ue791", Color: "#701516"}, //  "bspwmrc": {Icon: "\uf355", Color: "#2F2F2F"}, //  "BUILD": {Icon: "\ue63a", Color: "#89E051"}, //  "build.gradle": {Icon: "\ue660", Color: "#005F87"}, //  "build.zig.zon": {Icon: "\ue6a9", Color: "#F69A1B"}, //  "bun.lockb": {Icon: "\ue76f", Color: "#EADCD1"}, //  "cantorrc": {Icon: "\uf373", Color: "#1C99F3"}, //  "Cargo.lock": {Icon: "\ue7a8", Color: "#DEA584"}, //  "Cargo.toml": {Icon: "\ue7a8", Color: "#DEA584"}, //  "checkhealth": {Icon: "\U000f04d9", Color: "#75B4FB"}, // 󰓙 "CMakeLists.txt": {Icon: "\ue794", Color: "#DCE3EB"}, //  "CODE_OF_CONDUCT": {Icon: "\uf4ae", Color: "#E41662"}, //  "CODE_OF_CONDUCT.md": {Icon: "\uf4ae", Color: "#E41662"}, //  "CODE-OF-CONDUCT.md": {Icon: "\uf4ae", Color: "#E41662"}, //  "commit_editmsg": {Icon: "\ue702", Color: "#F54D27"}, //  "COMMIT_EDITMSG": {Icon: "\ue702", Color: "#E54D18"}, //  "commitlint.config.js": {Icon: "\U000f0718", Color: "#039688"}, //  "commitlint.config.ts": {Icon: "\U000f0718", Color: "#039688"}, //  "compose.yaml": {Icon: "\uf21f", Color: "#0088C9"}, //  "compose.yml": {Icon: "\uf21f", Color: "#0088C9"}, //  "config": {Icon: "\uf013", Color: "#696969"}, //  "containerfile": {Icon: "\uf21f", Color: "#0088C9"}, //  "copying": {Icon: "\U000f0124", Color: "#FF5821"}, // 󰄤 "copying.lesser": {Icon: "\ue60a", Color: "#CBCB41"}, //  "docker-compose.yaml": {Icon: "\uf21f", Color: "#0088C9"}, //  "docker-compose.yml": {Icon: "\uf21f", Color: "#0088C9"}, //  "dockerfile": {Icon: "\uf21f", Color: "#0088C9"}, //  "Dockerfile": {Icon: "\uf308", Color: "#458EE6"}, //  "ds_store": {Icon: "\uf179", Color: "#DDDDDD"}, //  "eslint.config.cjs": {Icon: "\U000f0c7a", Color: "#3F52B5"}, // 󰱺 "eslint.config.js": {Icon: "\U000f0c7a", Color: "#3F52B5"}, // 󰱺 "eslint.config.mjs": {Icon: "\U000f0c7a", Color: "#3F52B5"}, // 󰱺 "eslint.config.ts": {Icon: "\U000f0c7a", Color: "#3F52B5"}, // 󰱺 "ext_typoscript_setup.txt": {Icon: "\ue772", Color: "#FF8700"}, //  "favicon.ico": {Icon: "\ue623", Color: "#CBCB41"}, //  "fp-info-cache": {Icon: "\uf34c", Color: "#FFFFFF"}, //  "fp-lib-table": {Icon: "\uf34c", Color: "#FFFFFF"}, //  "FreeCAD.conf": {Icon: "\uf336", Color: "#CB333B"}, //  "gemfile$": {Icon: "\ue791", Color: "#701516"}, //  "gitignore_global": {Icon: "\U000f02a2", Color: "#E64A19"}, // 󰊢 "gnumakefile": {Icon: "\ueba2", Color: "#EF5351"}, //  "GNUmakefile": {Icon: "\ue779", Color: "#6D8086"}, //  "go.mod": {Icon: "\ue627", Color: "#02ACC1"}, //  "go.sum": {Icon: "\ue627", Color: "#02ACC1"}, //  "go.work": {Icon: "\ue627", Color: "#02ACC1"}, //  "gradle": {Icon: "\ue660", Color: "#005F87"}, //  "gradle-wrapper.properties": {Icon: "\ue660", Color: "#005F87"}, //  "gradle.properties": {Icon: "\ue660", Color: "#005F87"}, //  "gradlew": {Icon: "\ue660", Color: "#005F87"}, //  "gruntfile.babel.js": {Icon: "\ue611", Color: "#E37933"}, //  "gruntfile.coffee": {Icon: "\ue611", Color: "#E37933"}, //  "gruntfile.js": {Icon: "\ue611", Color: "#E37933"}, //  "gruntfile.ls": {Icon: "\ue611", Color: "#E37933"}, //  "gruntfile.ts": {Icon: "\ue611", Color: "#E37933"}, //  "gtkrc": {Icon: "\uf362", Color: "#FFFFFF"}, //  "gulpfile.babel.js": {Icon: "\ue610", Color: "#CC3E44"}, //  "gulpfile.coffee": {Icon: "\ue610", Color: "#CC3E44"}, //  "gulpfile.js": {Icon: "\ue610", Color: "#CC3E44"}, //  "gulpfile.ls": {Icon: "\ue610", Color: "#CC3E44"}, //  "gulpfile.ts": {Icon: "\ue610", Color: "#CC3E44"}, //  "hidden": {Icon: "\uf023", Color: "#555555"}, //  "hypridle.conf": {Icon: "\uf359", Color: "#00AAAE"}, //  "hyprland.conf": {Icon: "\uf359", Color: "#00AAAE"}, //  "hyprlock.conf": {Icon: "\uf359", Color: "#00AAAE"}, //  "hyprpaper.conf": {Icon: "\uf359", Color: "#00AAAE"}, //  "i3blocks.conf": {Icon: "\uf35a", Color: "#E8EBEE"}, //  "i3status.conf": {Icon: "\uf35a", Color: "#E8EBEE"}, //  "include": {Icon: "\ue5fc", Color: "#EEEEEE"}, //  "index.theme": {Icon: "\uee72", Color: "#2DB96F"}, //  "ionic.config.json": {Icon: "\ue66b", Color: "#508FF7"}, //  "justfile": {Icon: "\uf0ad", Color: "#6D8086"}, //  "kalgebrarc": {Icon: "\uf373", Color: "#1C99F3"}, //  "kdeglobals": {Icon: "\uf373", Color: "#1C99F3"}, //  "kdenlive-layoutsrc": {Icon: "\uf33c", Color: "#83B8F2"}, //  "kdenliverc": {Icon: "\uf33c", Color: "#83B8F2"}, //  "kritadisplayrc": {Icon: "\uf33d", Color: "#F245FB"}, //  "kritarc": {Icon: "\uf33d", Color: "#F245FB"}, //  "lib": {Icon: "\U000f1517", Color: "#8BC34A"}, // 󱔗 "LICENSE": {Icon: "\uf02d", Color: "#EDEDED"}, //  "LICENSE.md": {Icon: "\uf02d", Color: "#EDEDED"}, //  "localized": {Icon: "\uf179", Color: "#DDDDDD"}, //  "lxde-rc.xml": {Icon: "\uf363", Color: "#909090"}, //  "lxqt.conf": {Icon: "\uf364", Color: "#0192D3"}, //  "Makefile": {Icon: "\ue673", Color: "#FEFEFE"}, //  "mix.lock": {Icon: "\ue62d", Color: "#A074C4"}, //  "mpv.conf": {Icon: "\uf36e", Color: "#3B1342"}, //  "node_modules": {Icon: "\ue718", Color: "#E8274B"}, //  "npmignore": {Icon: "\ue71e", Color: "#E8274B"}, //  "nuxt.config.cjs": {Icon: "\U000f1106", Color: "#00C58E"}, // 󱄆 "nuxt.config.js": {Icon: "\U000f1106", Color: "#00C58E"}, // 󱄆 "nuxt.config.mjs": {Icon: "\U000f1106", Color: "#00C58E"}, // 󱄆 "nuxt.config.ts": {Icon: "\U000f1106", Color: "#00C58E"}, // 󱄆 "package-lock.json": {Icon: "\ued0d", Color: "#F54436"}, //  "package.json": {Icon: "\ued0d", Color: "#4CAF51"}, //  "PKGBUILD": {Icon: "\uf303", Color: "#0F94D2"}, //  "platformio.ini": {Icon: "\ue682", Color: "#F6822B"}, //  "pom.xml": {Icon: "\U000f06d3", Color: "#FF7043"}, // 󰛓 "prettier.config.cjs": {Icon: "\ue6b4", Color: "#4285F4"}, //  "prettier.config.js": {Icon: "\ue6b4", Color: "#4285F4"}, //  "prettier.config.mjs": {Icon: "\ue6b4", Color: "#4285F4"}, //  "prettier.config.ts": {Icon: "\ue6b4", Color: "#4285F4"}, //  "PrusaSlicer.ini": {Icon: "\uf351", Color: "#EC6B23"}, //  "PrusaSlicerGcodeViewer.ini": {Icon: "\uf351", Color: "#EC6B23"}, //  "py.typed": {Icon: "\ue606", Color: "#ffbc03"}, //  "QtProject.conf": {Icon: "\uf375", Color: "#40CD52"}, //  "R": {Icon: "\U000f07d4", Color: "#2266BA"}, // 󰟔 "README": {Icon: "\U000f00ba", Color: "#EDEDED"}, // 󰂺 "README.md": {Icon: "\U000f00ba", Color: "#EDEDED"}, // 󰂺 "robots.txt": {Icon: "\U000f06a9", Color: "#5D7096"}, // 󰚩 "rubydoc": {Icon: "\ue73b", Color: "#F32C24"}, //  "SECURITY": {Icon: "\U000f0483", Color: "#BEC4C9"}, // 󰒃 "SECURITY.md": {Icon: "\U000f0483", Color: "#BEC4C9"}, // 󰒃 "settings.gradle": {Icon: "\ue660", Color: "#005F87"}, //  "svelte.config.js": {Icon: "\ue697", Color: "#FF5821"}, //  "sxhkdrc": {Icon: "\uf355", Color: "#2F2F2F"}, //  "sym-lib-table": {Icon: "\uf34c", Color: "#FFFFFF"}, //  "tailwind.config.js": {Icon: "\U000f13ff", Color: "#4DB6AC"}, // 󱏿 "tailwind.config.mjs": {Icon: "\U000f13ff", Color: "#4DB6AC"}, // 󱏿 "tailwind.config.ts": {Icon: "\U000f13ff", Color: "#4DB6AC"}, // 󱏿 "tmux.conf": {Icon: "\uebc8", Color: "#14BA19"}, //  "tmux.conf.local": {Icon: "\uebc8", Color: "#14BA19"}, //  "tsconfig.json": {Icon: "\ue628", Color: "#0188D1"}, //  "unlicense": {Icon: "\ue60a", Color: "#D0BF41"}, //  "vagrantfile$": {Icon: "\uf2b8", Color: "#1868F2"}, //  "vlcrc": {Icon: "\U000f057c", Color: "#E85E00"}, // 󰕼 "webpack": {Icon: "\U000f072b", Color: "#519ABA"}, // 󰜫 "weston.ini": {Icon: "\uf367", Color: "#FFBB01"}, //  "WORKSPACE": {Icon: "\ue63a", Color: "#89E051"}, //  "WORKSPACE.bzlmod": {Icon: "\ue63a", Color: "#89E051"}, //  "xmobarrc": {Icon: "\uf35e", Color: "#FD4D5D"}, //  "xmobarrc.hs": {Icon: "\uf35e", Color: "#FD4D5D"}, //  "xmonad.hs": {Icon: "\uf35e", Color: "#FD4D5D"}, //  "xorg.conf": {Icon: "\uf369", Color: "#E54D18"}, //  "xsettingsd.conf": {Icon: "\uf369", Color: "#E54D18"}, //  "yarn.lock": {Icon: "\ue6a7", Color: "#0188D1"}, //  } var extIconMap = map[string]IconProperties{ ".3gp": {Icon: "\uf03d", Color: "#F6822B"}, //  ".3mf": {Icon: "\U000f01a7", Color: "#888888"}, // 󰆧 ".7z": {Icon: "\uf410", Color: "#ECA517"}, //  ".DS_store": {Icon: "\uf179", Color: "#A2AAAD"}, //  ".a": {Icon: "\U000f1517", Color: "#8BC34A"}, // 󱔗 ".aac": {Icon: "\uf001", Color: "#20C2E3"}, //  ".adb": {Icon: "\ue6b5", Color: "#22FFFF"}, //  ".ads": {Icon: "\ue6b5", Color: "#22FFFF"}, //  ".ai": {Icon: "\ue7b4", Color: "#D0BF41"}, //  ".aif": {Icon: "\uf001", Color: "#00AFFF"}, //  ".aiff": {Icon: "\U000f0386", Color: "#EE534F"}, // 󰎆 ".android": {Icon: "\ue70e", Color: "#66AF3D"}, //  ".ape": {Icon: "\uf001", Color: "#00AFFF"}, //  ".apk": {Icon: "\ue70e", Color: "#8BC34A"}, //  ".app": {Icon: "\ueae8", Color: "#9F0500"}, //  ".apple": {Icon: "\ue635", Color: "#A2AAAD"}, //  ".applescript": {Icon: "\uf302", Color: "#78919C"}, //  ".asc": {Icon: "\U000f0306", Color: "#25A79A"}, // 󰌆 ".asm": {Icon: "\ue637", Color: "#0091BD"}, //  ".ass": {Icon: "\U000f0a16", Color: "#FFB713"}, // 󰨖 ".astro": {Icon: "\ue6b3", Color: "#FF6D00"}, //  ".avi": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".avif": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".avro": {Icon: "\ue60b", Color: "#965824"}, //  ".awk": {Icon: "\U000f018d", Color: "#FF7043"}, // 󰆍 ".azcli": {Icon: "\uebd8", Color: "#2088E5"}, //  ".bak": {Icon: "\U000f006f", Color: "#6D8086"}, // 󰁯 ".bash": {Icon: "\uebca", Color: "#FF7043"}, //  ".bash_history": {Icon: "\ue795", Color: "#8DC149"}, //  ".bash_profile": {Icon: "\ue795", Color: "#8DC149"}, //  ".bashrc": {Icon: "\ue795", Color: "#8DC149"}, //  ".bat": {Icon: "\U000f018d", Color: "#FF7043"}, // 󰆍 ".bats": {Icon: "\U000f0b5f", Color: "#D2D2D2"}, // 󰭟 ".bazel": {Icon: "\ue63a", Color: "#44A047"}, //  ".bib": {Icon: "\U000f1517", Color: "#8BC34A"}, // 󱔗 ".bicep": {Icon: "\U000f0fd7", Color: "#FBC02D"}, // 󰿗 ".bicepparam": {Icon: "\ue63b", Color: "#797DAC"}, //  ".blade.php": {Icon: "\uf2f7", Color: "#FF5252"}, //  ".blend": {Icon: "\U000f00ab", Color: "#ED8F30"}, // 󰂫 ".blp": {Icon: "\U000f0ebe", Color: "#458EE6"}, // 󰺾 ".bmp": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".brep": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".bz": {Icon: "\uf410", Color: "#ECA517"}, //  ".bz2": {Icon: "\uf410", Color: "#ECA517"}, //  ".bz3": {Icon: "\uf410", Color: "#ECA517"}, //  ".bzl": {Icon: "\ue63a", Color: "#44A047"}, //  ".c": {Icon: "\ue61e", Color: "#0188D1"}, //  ".c++": {Icon: "\ue61d", Color: "#0188D1"}, //  ".cab": {Icon: "\ue70f", Color: "#626262"}, //  ".cache": {Icon: "\uf49b", Color: "#FFFFFF"}, //  ".cast": {Icon: "\uf03d", Color: "#EA8220"}, //  ".cbl": {Icon: "\u2699", Color: "#005CA5"}, // ⚙ ".cc": {Icon: "\ue61d", Color: "#0188D1"}, //  ".ccm": {Icon: "\ue61d", Color: "#F34B7D"}, //  ".cfg": {Icon: "\uf013", Color: "#42A5F5"}, //  ".cjs": {Icon: "\ue60c", Color: "#CBCB41"}, //  ".class": {Icon: "\uf0f4", Color: "#2088E5"}, //  ".clj": {Icon: "\ue642", Color: "#2AB6F6"}, //  ".cljc": {Icon: "\ue642", Color: "#2AB6F6"}, //  ".cljd": {Icon: "\ue76a", Color: "#519ABA"}, //  ".cljs": {Icon: "\ue642", Color: "#2AB6F6"}, //  ".cls": {Icon: "\ue69b", Color: "#4B5163"}, //  ".cmake": {Icon: "\ue794", Color: "#DCE3EB"}, //  ".cmd": {Icon: "\uebc4", Color: "#FF7043"}, //  ".cob": {Icon: "\u2699", Color: "#005CA5"}, // ⚙ ".cobol": {Icon: "\u2699", Color: "#005CA5"}, // ⚙ ".coffee": {Icon: "\ue61b", Color: "#6F4E38"}, //  ".conda": {Icon: "\ue715", Color: "#43B02A"}, //  ".conf": {Icon: "\uf013", Color: "#696969"}, //  ".config.ru": {Icon: "\ue791", Color: "#701516"}, //  ".cp": {Icon: "\ue646", Color: "#0188D1"}, //  ".cpio": {Icon: "\uf410", Color: "#ECA517"}, //  ".cpp": {Icon: "\ue61d", Color: "#0188D1"}, //  ".cppm": {Icon: "\ue61d", Color: "#519ABA"}, //  ".cpy": {Icon: "\u2699", Color: "#005CA5"}, // ⚙ ".cr": {Icon: "\ue62f", Color: "#CFD8DD"}, //  ".crdownload": {Icon: "\uf019", Color: "#44CDA8"}, //  ".cs": {Icon: "\U000f031b", Color: "#0188D1"}, // 󰌛 ".csh": {Icon: "\U000f018d", Color: "#FF7043"}, // 󰆍 ".cshtml": {Icon: "\uf486", Color: "#42A5F5"}, //  ".cson": {Icon: "\ue61b", Color: "#6F4E38"}, //  ".csproj": {Icon: "\U000f0610", Color: "#AB48BC"}, // 󰘐 ".css": {Icon: "\ue749", Color: "#42A5F5"}, //  ".csv": {Icon: "\U000f021b", Color: "#8BC34A"}, // 󰈛 ".csx": {Icon: "\U000f031b", Color: "#0188D1"}, // 󰌛 ".cts": {Icon: "\ue628", Color: "#519ABA"}, //  ".cu": {Icon: "\ue64b", Color: "#89E051"}, //  ".cue": {Icon: "\U000f0cb9", Color: "#ED95AE"}, // 󰲹 ".cuh": {Icon: "\ue64b", Color: "#A074C4"}, //  ".cxx": {Icon: "\ue646", Color: "#0188D1"}, //  ".cxxm": {Icon: "\ue61d", Color: "#519ABA"}, //  ".d": {Icon: "\ue7af", Color: "#B03931"}, //  ".d.ts": {Icon: "\ue628", Color: "#0188D1"}, //  ".dart": {Icon: "\ue64c", Color: "#59B6F0"}, //  ".db": {Icon: "\uf1c0", Color: "#FFCA29"}, //  ".dconf": {Icon: "\ue706", Color: "#DAD8D8"}, //  ".deb": {Icon: "\uebc5", Color: "#D80651"}, //  ".desktop": {Icon: "\uf108", Color: "#56347C"}, //  ".diff": {Icon: "\uf4d2", Color: "#4262A2"}, //  ".djvu": {Icon: "\uf02d", Color: "#624262"}, //  ".dll": {Icon: "\U000f107c", Color: "#42A5F5"}, // 󱁼 ".doc": {Icon: "\U000f022c", Color: "#0188D1"}, // 󰈬 ".docx": {Icon: "\U000f022c", Color: "#0188D1"}, // 󰈬 ".dot": {Icon: "\U000f1049", Color: "#005F87"}, // 󱁉 ".download": {Icon: "\uf019", Color: "#44CDA8"}, //  ".drl": {Icon: "\ue28c", Color: "#FFAFAF"}, //  ".dropbox": {Icon: "\ue707", Color: "#2E63FF"}, //  ".ds_store": {Icon: "\uf179", Color: "#A2AAAD"}, //  ".dump": {Icon: "\uf1c0", Color: "#DAD8D8"}, //  ".dwg": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".dxf": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".ebook": {Icon: "\ue28b", Color: "#EAB16D"}, //  ".ebuild": {Icon: "\uf30d", Color: "#4C416E"}, //  ".editorconfig": {Icon: "\ue615", Color: "#626262"}, //  ".edn": {Icon: "\ue76a", Color: "#519ABA"}, //  ".eex": {Icon: "\ue62d", Color: "#9575CE"}, //  ".ejs": {Icon: "\ue618", Color: "#CBCB41"}, //  ".el": {Icon: "\ue632", Color: "#805EB7"}, //  ".elc": {Icon: "\ue632", Color: "#805EB7"}, //  ".elf": {Icon: "\ueae8", Color: "#9F0500"}, //  ".elm": {Icon: "\ue62c", Color: "#60B6CC"}, //  ".eln": {Icon: "\ue632", Color: "#8172BE"}, //  ".env": {Icon: "\uf462", Color: "#FAF743"}, //  ".eot": {Icon: "\ue659", Color: "#F54436"}, //  ".epp": {Icon: "\ue631", Color: "#FFA61A"}, //  ".epub": {Icon: "\ue28b", Color: "#EAB16D"}, //  ".erb": {Icon: "\U000f0d2d", Color: "#F54436"}, // 󰴭 ".erl": {Icon: "\uf23f", Color: "#F54436"}, //  ".ex": {Icon: "\ue62d", Color: "#9575CE"}, //  ".exe": {Icon: "\uf2d0", Color: "#E64A19"}, //  ".exs": {Icon: "\ue62d", Color: "#9575CE"}, //  ".f#": {Icon: "\ue7a7", Color: "#519ABA"}, //  ".f3d": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".f90": {Icon: "\U000f121a", Color: "#FF7043"}, // 󱈚 ".fbx": {Icon: "\uea8c", Color: "#2AB6F6"}, //  ".fcbak": {Icon: "\uf336", Color: "#6D8086"}, //  ".fcmacro": {Icon: "\uf336", Color: "#CB333B"}, //  ".fcmat": {Icon: "\uf336", Color: "#CB333B"}, //  ".fcparam": {Icon: "\uf336", Color: "#CB333B"}, //  ".fcscript": {Icon: "\uf336", Color: "#CB333B"}, //  ".fcstd": {Icon: "\uf336", Color: "#CB333B"}, //  ".fcstd1": {Icon: "\uf336", Color: "#CB333B"}, //  ".fctb": {Icon: "\uf336", Color: "#CB333B"}, //  ".fctl": {Icon: "\uf336", Color: "#CB333B"}, //  ".fdmdownload": {Icon: "\uf019", Color: "#44CDA8"}, //  ".fish": {Icon: "\U000f023a", Color: "#FF7043"}, // 󰈺 ".flac": {Icon: "\U000f0386", Color: "#EE534F"}, // 󰎆 ".flc": {Icon: "\uf031", Color: "#ECECEC"}, //  ".flf": {Icon: "\uf031", Color: "#ECECEC"}, //  ".flv": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".fnl": {Icon: "\ue6af", Color: "#FFF3D7"}, //  ".fodg": {Icon: "\uf379", Color: "#FFFB57"}, //  ".fodp": {Icon: "\uf37a", Color: "#FE9C45"}, //  ".fods": {Icon: "\uf378", Color: "#78FC4E"}, //  ".fodt": {Icon: "\uf37c", Color: "#2DCBFD"}, //  ".font": {Icon: "\ue659", Color: "#F54436"}, //  ".fs": {Icon: "\ue7a7", Color: "#31B9DB"}, //  ".fsi": {Icon: "\ue7a7", Color: "#31B9DB"}, //  ".fsscript": {Icon: "\ue7a7", Color: "#519ABA"}, //  ".fsx": {Icon: "\ue7a7", Color: "#31B9DB"}, //  ".gcode": {Icon: "\U000f0af4", Color: "#505075"}, // 󰫴 ".gd": {Icon: "\ue65f", Color: "#42A5F5"}, //  ".gdoc": {Icon: "\uf1c2", Color: "#01D000"}, //  ".gem": {Icon: "\ue21e", Color: "#C90F02"}, //  ".gemfile": {Icon: "\ueb48", Color: "#E63936"}, //  ".gemspec": {Icon: "\ue21e", Color: "#C90F02"}, //  ".gform": {Icon: "\uf298", Color: "#01D000"}, //  ".gif": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".git": {Icon: "\U000f02a2", Color: "#EC6B23"}, // 󰊢 ".glb": {Icon: "\uf1b2", Color: "#FFA61A"}, //  ".gnumakefile": {Icon: "\ueba2", Color: "#EF5351"}, //  ".go": {Icon: "\ue627", Color: "#02ACC1"}, //  ".godot": {Icon: "\ue65f", Color: "#42A5F5"}, //  ".gpr": {Icon: "\ue6b5", Color: "#22FFFF"}, //  ".gql": {Icon: "\U000f0877", Color: "#EC417A"}, // 󰡷 ".gradle": {Icon: "\ue660", Color: "#0397A7"}, //  ".graphql": {Icon: "\U000f0877", Color: "#EC417A"}, // 󰡷 ".gresource": {Icon: "\uf362", Color: "#FFFFFF"}, //  ".groovy": {Icon: "\ue775", Color: "#005F87"}, //  ".gsheet": {Icon: "\uf1c3", Color: "#97BA6A"}, //  ".gslides": {Icon: "\uf1c4", Color: "#FFFF00"}, //  ".guardfile": {Icon: "\ue21e", Color: "#626262"}, //  ".gv": {Icon: "\U000f1049", Color: "#005F87"}, // 󱁉 ".gz": {Icon: "\uf410", Color: "#ECA517"}, //  ".h": {Icon: "\uf0fd", Color: "#A074C4"}, //  ".haml": {Icon: "\ue664", Color: "#F4521E"}, //  ".hbs": {Icon: "\U000f15de", Color: "#FF7043"}, // 󱗞 ".hc": {Icon: "\U000f00a2", Color: "#FAF743"}, // 󰂢 ".heex": {Icon: "\ue62d", Color: "#9575CE"}, //  ".hex": {Icon: "\U000f12a7", Color: "#25A79A"}, // 󱊧 ".hh": {Icon: "\uf0fd", Color: "#A074C4"}, //  ".hpp": {Icon: "\uf0fd", Color: "#A074C4"}, //  ".hrl": {Icon: "\ue7b1", Color: "#B83998"}, //  ".hs": {Icon: "\ue61f", Color: "#FFA726"}, //  ".htm": {Icon: "\uf13b", Color: "#E44E27"}, //  ".html": {Icon: "\uf13b", Color: "#E44E27"}, //  ".huff": {Icon: "\U000f0858", Color: "#CFD8DD"}, // 󰡘 ".hurl": {Icon: "\uf0ec", Color: "#FF0288"}, //  ".hx": {Icon: "\ue666", Color: "#F68713"}, //  ".hxx": {Icon: "\uf0fd", Color: "#A074C4"}, //  ".ical": {Icon: "\uf073", Color: "#2B9EF3"}, //  ".icalendar": {Icon: "\uf073", Color: "#2B9EF3"}, //  ".ico": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".ics": {Icon: "\U000f01ee", Color: "#42A5F5"}, // 󰇮 ".ifb": {Icon: "\uf073", Color: "#2B9EF3"}, //  ".ifc": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".ige": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".iges": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".igs": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".image": {Icon: "\uf1c5", Color: "#CBCB41"}, //  ".img": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".iml": {Icon: "\U000f022e", Color: "#8BC34A"}, // 󰈮 ".import": {Icon: "\uf0c6", Color: "#ECECEC"}, //  ".info": {Icon: "\uf129", Color: "#FFF3D7"}, //  ".ini": {Icon: "\uf013", Color: "#42A5F5"}, //  ".ino": {Icon: "\uf34b", Color: "#01979D"}, //  ".ipynb": {Icon: "\ue80f", Color: "#F57D01"}, //  ".iso": {Icon: "\uede9", Color: "#B1BEC5"}, //  ".ixx": {Icon: "\ue61d", Color: "#519ABA"}, //  ".j2c": {Icon: "\uf1c5", Color: "#4B5163"}, //  ".j2k": {Icon: "\uf1c5", Color: "#4B5163"}, //  ".jad": {Icon: "\ue256", Color: "#F19210"}, //  ".jar": {Icon: "\U000f06ca", Color: "#F19210"}, // 󰛊 ".java": {Icon: "\uf0f4", Color: "#F19210"}, //  ".jfi": {Icon: "\uf1c5", Color: "#626262"}, //  ".jfif": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".jif": {Icon: "\uf1c5", Color: "#626262"}, //  ".jl": {Icon: "\ue624", Color: "#338A23"}, //  ".jmd": {Icon: "\uf48a", Color: "#519ABA"}, //  ".jp2": {Icon: "\uf1c5", Color: "#626262"}, //  ".jpe": {Icon: "\uf1c5", Color: "#626262"}, //  ".jpeg": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".jpg": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".jpx": {Icon: "\uf1c5", Color: "#626262"}, //  ".js": {Icon: "\U000f031e", Color: "#FFCA29"}, // 󰌞 ".json": {Icon: "\ue60b", Color: "#FAA825"}, //  ".json5": {Icon: "\ue60b", Color: "#FAA825"}, //  ".jsonc": {Icon: "\ue60b", Color: "#FAA825"}, //  ".jsx": {Icon: "\ued46", Color: "#FFCA29"}, //  ".jwmrc": {Icon: "\uf35b", Color: "#007AC2"}, //  ".jxl": {Icon: "\uf1c5", Color: "#727252"}, //  ".kbx": {Icon: "\U000f0bc4", Color: "#537662"}, // 󰯄 ".kdb": {Icon: "\uf23e", Color: "#529B34"}, //  ".kdbx": {Icon: "\uf23e", Color: "#529B34"}, //  ".kdenlive": {Icon: "\uf33c", Color: "#83B8F2"}, //  ".kdenlivetitle": {Icon: "\uf33c", Color: "#83B8F2"}, //  ".kicad_dru": {Icon: "\uf34c", Color: "#FFFFFF"}, //  ".kicad_mod": {Icon: "\uf34c", Color: "#FFFFFF"}, //  ".kicad_pcb": {Icon: "\uf34c", Color: "#FFFFFF"}, //  ".kicad_prl": {Icon: "\uf34c", Color: "#FFFFFF"}, //  ".kicad_pro": {Icon: "\uf34c", Color: "#FFFFFF"}, //  ".kicad_sch": {Icon: "\uf34c", Color: "#FFFFFF"}, //  ".kicad_sym": {Icon: "\uf34c", Color: "#FFFFFF"}, //  ".kicad_wks": {Icon: "\uf34c", Color: "#FFFFFF"}, //  ".ko": {Icon: "\uf17c", Color: "#DDDDDD"}, //  ".kpp": {Icon: "\uf33d", Color: "#F245FB"}, //  ".kra": {Icon: "\uf33d", Color: "#F245FB"}, //  ".krz": {Icon: "\uf33d", Color: "#F245FB"}, //  ".ksh": {Icon: "\U000f018d", Color: "#FF7043"}, // 󰆍 ".kt": {Icon: "\ue634", Color: "#1A95D9"}, //  ".kts": {Icon: "\ue634", Color: "#1A95D9"}, //  ".latex": {Icon: "\ue69b", Color: "#626262"}, //  ".lck": {Icon: "\ue672", Color: "#BBBBBB"}, //  ".leex": {Icon: "\ue62d", Color: "#9575CE"}, //  ".less": {Icon: "\ued48", Color: "#0277BD"}, //  ".lff": {Icon: "\uf031", Color: "#ECECEC"}, //  ".lhs": {Icon: "\ue777", Color: "#A074C4"}, //  ".license": {Icon: "\U000f0124", Color: "#FFCA29"}, // 󰄤 ".liquid": {Icon: "\uf043", Color: "#2AB6F6"}, //  ".localized": {Icon: "\uf179", Color: "#A2AAAD"}, //  ".lock": {Icon: "\uf023", Color: "#FFD550"}, //  ".log": {Icon: "\uf0f6", Color: "#ECA517"}, //  ".lrc": {Icon: "\U000f0a16", Color: "#FFA61A"}, // 󰨖 ".lua": {Icon: "\ue620", Color: "#42A5F5"}, //  ".luac": {Icon: "\ue620", Color: "#519ABA"}, //  ".luau": {Icon: "\ue620", Color: "#519ABA"}, //  ".lz": {Icon: "\uf410", Color: "#ECA517"}, //  ".lz4": {Icon: "\uf410", Color: "#ECA517"}, //  ".lzh": {Icon: "\uf410", Color: "#ECA517"}, //  ".lzma": {Icon: "\uf410", Color: "#ECA517"}, //  ".lzo": {Icon: "\uf410", Color: "#ECA517"}, //  ".m": {Icon: "\ue61e", Color: "#599EFF"}, //  ".m3u": {Icon: "\U000f0cb9", Color: "#ED95AE"}, // 󰲹 ".m3u8": {Icon: "\U000f0cb9", Color: "#ED95AE"}, // 󰲹 ".m4a": {Icon: "\U000f0386", Color: "#EE534F"}, // 󰎆 ".m4v": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".magnet": {Icon: "\uf076", Color: "#9F0500"}, //  ".makefile": {Icon: "\ue673", Color: "#FEFEFE"}, //  ".markdown": {Icon: "\ueb1d", Color: "#42A5F5"}, //  ".material": {Icon: "\U000f0509", Color: "#B83998"}, // 󰔉 ".md": {Icon: "\ueb1d", Color: "#42A5F5"}, //  ".md5": {Icon: "\U000f0565", Color: "#8C86AF"}, // 󰕥 ".mdx": {Icon: "\ueb1d", Color: "#FFCA29"}, //  ".mint": {Icon: "\ue7a4", Color: "#44A047"}, //  ".mjs": {Icon: "\U000f031e", Color: "#FFCA29"}, // 󰌞 ".mk": {Icon: "\ue795", Color: "#626262"}, //  ".mkd": {Icon: "\uf48a", Color: "#519ABA"}, //  ".mkv": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".ml": {Icon: "\ue67a", Color: "#FF9800"}, //  ".mli": {Icon: "\ue67a", Color: "#FF9800"}, //  ".mm": {Icon: "\ue61d", Color: "#599EFF"}, //  ".mo": {Icon: "\U000f05ca", Color: "#7986CB"}, // 󰗊 ".mobi": {Icon: "\ue28b", Color: "#EAB16D"}, //  ".mojo": {Icon: "\ue780", Color: "#FF7043"}, //  ".mov": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".mp3": {Icon: "\U000f0386", Color: "#EE534F"}, // 󰎆 ".mp4": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".mpp": {Icon: "\ue61d", Color: "#519ABA"}, //  ".msf": {Icon: "\uf370", Color: "#137BE1"}, //  ".msi": {Icon: "\uf2d0", Color: "#E64A19"}, //  ".mts": {Icon: "\ue628", Color: "#519ABA"}, //  ".mustache": {Icon: "\U000f15de", Color: "#FF7043"}, // 󱗞 ".nfo": {Icon: "\uf129", Color: "#FFF3D7"}, //  ".nim": {Icon: "\ue677", Color: "#FFCA29"}, //  ".nix": {Icon: "\uf313", Color: "#5175C2"}, //  ".node": {Icon: "\U000f0399", Color: "#E8274B"}, // 󰎙 ".npmignore": {Icon: "\ue71e", Color: "#E8274B"}, //  ".nswag": {Icon: "\ue60b", Color: "#85EA2D"}, //  ".nu": {Icon: "\U000f018d", Color: "#FF7043"}, // 󰆍 ".o": {Icon: "\uea8c", Color: "#2AB6F6"}, //  ".obj": {Icon: "\uea8c", Color: "#2AB6F6"}, //  ".odin": {Icon: "\U000f07e2", Color: "#3882D2"}, // 󰟢 ".odf": {Icon: "\uf37b", Color: "#FF5A96"}, //  ".odg": {Icon: "\uf379", Color: "#FFFB57"}, //  ".odp": {Icon: "\uf37a", Color: "#FE9C45"}, //  ".ods": {Icon: "\uf378", Color: "#78FC4E"}, //  ".odt": {Icon: "\uf37c", Color: "#2DCBFD"}, //  ".ogg": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".ogv": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".opus": {Icon: "\U000f0223", Color: "#EA8220"}, // 󰈣 ".org": {Icon: "\ue633", Color: "#56B6C2"}, //  ".otf": {Icon: "\ue659", Color: "#F54436"}, //  ".out": {Icon: "\ueae8", Color: "#9F0500"}, //  ".part": {Icon: "\uf43a", Color: "#628262"}, //  ".patch": {Icon: "\uf440", Color: "#4262A2"}, //  ".pck": {Icon: "\uf487", Color: "#5D8096"}, //  ".pdf": {Icon: "\uf1c1", Color: "#EF5351"}, //  ".php": {Icon: "\U000f031f", Color: "#2088E5"}, // 󰌟 ".pl": {Icon: "\U000f03d2", Color: "#EF5351"}, // 󰏒 ".pls": {Icon: "\U000f0cb9", Color: "#ED95AE"}, // 󰲹 ".ply": {Icon: "\U000f01a7", Color: "#888888"}, // 󰆧 ".pm": {Icon: "\ue769", Color: "#9575CE"}, //  ".png": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".po": {Icon: "\U000f05ca", Color: "#7986CB"}, // 󰗊 ".pot": {Icon: "\U000f05ca", Color: "#7986CB"}, // 󰗊 ".pp": {Icon: "\ue631", Color: "#FFA61A"}, //  ".ppt": {Icon: "\U000f0227", Color: "#D14525"}, // 󰈧 ".pptx": {Icon: "\U000f0227", Color: "#D14525"}, // 󰈧 ".prisma": {Icon: "\ue684", Color: "#00BFA5"}, //  ".pro": {Icon: "\U000f03d2", Color: "#EF5351"}, // 󰏒 ".procfile": {Icon: "\ue607", Color: "#6964BA"}, //  ".properties": {Icon: "\uf013", Color: "#42A5F5"}, //  ".ps1": {Icon: "\U000f0a0a", Color: "#04A9F4"}, // 󰨊 ".psb": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".psd": {Icon: "\ue7b8", Color: "#25A6A0"}, //  ".psd1": {Icon: "\U000f0a0a", Color: "#04A9F4"}, // 󰨊 ".psm1": {Icon: "\U000f0a0a", Color: "#04A9F4"}, // 󰨊 ".pub": {Icon: "\U000f0306", Color: "#25A79A"}, // 󰌆 ".pxd": {Icon: "\ue606", Color: "#00AFFF"}, //  ".pxi": {Icon: "\ue606", Color: "#00AFFF"}, //  ".pxm": {Icon: "\uf1c5", Color: "#626262"}, //  ".py": {Icon: "\ued1b", Color: "#FED836"}, //  ".pyc": {Icon: "\ue606", Color: "#FFA61A"}, //  ".pyd": {Icon: "\ue606", Color: "#E3C58E"}, //  ".pyi": {Icon: "\ue606", Color: "#FFA61A"}, //  ".pyo": {Icon: "\ue606", Color: "#E3C58E"}, //  ".pyw": {Icon: "\ue606", Color: "#00AFFF"}, //  ".pyx": {Icon: "\ue606", Color: "#00AFFF"}, //  ".qm": {Icon: "\U000f05ca", Color: "#2596BE"}, // 󰗊 ".qml": {Icon: "\uf375", Color: "#42CD52"}, //  ".qrc": {Icon: "\uf375", Color: "#40CD52"}, //  ".qss": {Icon: "\uf375", Color: "#40CD52"}, //  ".query": {Icon: "\ue21c", Color: "#90A850"}, //  ".r": {Icon: "\ue68a", Color: "#1976D3"}, //  ".rake": {Icon: "\ue791", Color: "#701516"}, //  ".rakefile": {Icon: "\ue21e", Color: "#C90F02"}, //  ".rar": {Icon: "\uf410", Color: "#ECA517"}, //  ".razor": {Icon: "\uf1fa", Color: "#207245"}, //  ".rb": {Icon: "\U000f0d2d", Color: "#F54436"}, // 󰴭 ".rdata": {Icon: "\uf25d", Color: "#458EE6"}, //  ".rdb": {Icon: "\ue76d", Color: "#C90F02"}, //  ".rdoc": {Icon: "\uf48a", Color: "#519ABA"}, //  ".rds": {Icon: "\uf25d", Color: "#458EE6"}, //  ".readme": {Icon: "\uf05a", Color: "#42A5F5"}, //  ".res": {Icon: "\ue688", Color: "#EF5351"}, //  ".resi": {Icon: "\ue688", Color: "#FFB300"}, //  ".rlib": {Icon: "\ue7a8", Color: "#DEA584"}, //  ".rmd": {Icon: "\ue68a", Color: "#1976D3"}, //  ".rpm": {Icon: "\ue7bb", Color: "#EE0000"}, //  ".rproj": {Icon: "\U000f05c6", Color: "#358A5B"}, // 󰗆 ".rs": {Icon: "\ue68b", Color: "#FF7043"}, //  ".rspec": {Icon: "\ue21e", Color: "#C90F02"}, //  ".rspec_parallel": {Icon: "\ue21e", Color: "#C90F02"}, //  ".rspec_status": {Icon: "\ue21e", Color: "#C90F02"}, //  ".rss": {Icon: "\uf09e", Color: "#965824"}, //  ".rtf": {Icon: "\U000f022c", Color: "#0188D1"}, // 󰈬 ".ru": {Icon: "\ue21e", Color: "#C90F02"}, //  ".rubydoc": {Icon: "\ue73b", Color: "#C90F02"}, //  ".s": {Icon: "\ue637", Color: "#0091BD"}, //  ".sass": {Icon: "\ue603", Color: "#EC417A"}, //  ".sbt": {Icon: "\ue68d", Color: "#0277BD"}, //  ".sc": {Icon: "\ue68e", Color: "#F54436"}, //  ".scad": {Icon: "\uf34e", Color: "#F9D72C"}, //  ".scala": {Icon: "\ue68e", Color: "#F54436"}, //  ".scm": {Icon: "\U000f0627", Color: "#F54436"}, // 󰘧 ".scss": {Icon: "\ue603", Color: "#EC417A"}, //  ".sh": {Icon: "\U000f018d", Color: "#FF7043"}, // 󰆍 ".sha1": {Icon: "\U000f0565", Color: "#8C86AF"}, // 󰕥 ".sha224": {Icon: "\U000f0565", Color: "#8C86AF"}, // 󰕥 ".sha256": {Icon: "\U000f0565", Color: "#8C86AF"}, // 󰕥 ".sha384": {Icon: "\U000f0565", Color: "#8C86AF"}, // 󰕥 ".sha512": {Icon: "\U000f0565", Color: "#8C86AF"}, // 󰕥 ".shell": {Icon: "\ue795", Color: "#89E051"}, //  ".sig": {Icon: "\u03bb", Color: "#DC682E"}, // Λ ".signature": {Icon: "\u03bb", Color: "#DC682E"}, // Λ ".skp": {Icon: "\uea8c", Color: "#2AB6F6"}, //  ".sldasm": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".sldprt": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".slim": {Icon: "\ue692", Color: "#F57F19"}, //  ".sln": {Icon: "\U000f0610", Color: "#AB48BC"}, // 󰘐 ".slvs": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".sml": {Icon: "\u03bb", Color: "#DC682E"}, // Λ ".so": {Icon: "\U000f107c", Color: "#42A5F5"}, // 󱁼 ".sol": {Icon: "\ue656", Color: "#0188D1"}, //  ".spec.js": {Icon: "\uf499", Color: "#FFCA29"}, //  ".spec.jsx": {Icon: "\uf499", Color: "#FFCA29"}, //  ".spec.ts": {Icon: "\uf499", Color: "#519ABA"}, //  ".spec.tsx": {Icon: "\uf499", Color: "#0188D1"}, //  ".sql": {Icon: "\uf1c0", Color: "#CFCA99"}, //  ".sqlite": {Icon: "\uf1c0", Color: "#CFCA99"}, //  ".sqlite3": {Icon: "\uf1c0", Color: "#CFCA99"}, //  ".srt": {Icon: "\U000f0a16", Color: "#FFA61A"}, // 󰨖 ".ssa": {Icon: "\U000f0a16", Color: "#FFA61A"}, // 󰨖 ".ste": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".step": {Icon: "\U000f0eeb", Color: "#839463"}, // 󰻫 ".stl": {Icon: "\uea8c", Color: "#2AB6F6"}, //  ".stp": {Icon: "\uea8c", Color: "#2AB6F6"}, //  ".strings": {Icon: "\U000f05ca", Color: "#2596BE"}, // 󰗊 ".sty": {Icon: "\ue69b", Color: "#42A5F5"}, //  ".styl": {Icon: "\ue759", Color: "#C0CA33"}, //  ".stylus": {Icon: "\ue600", Color: "#83C837"}, //  ".sub": {Icon: "\U000f0a16", Color: "#FFA61A"}, // 󰨖 ".sublime": {Icon: "\ue7aa", Color: "#DC682E"}, //  ".suo": {Icon: "\U000f0610", Color: "#AB48BC"}, // 󰘐 ".sv": {Icon: "\U000f035b", Color: "#FF7043"}, // 󰍛 ".svelte": {Icon: "\ue697", Color: "#FF5821"}, //  ".svg": {Icon: "\U000f0721", Color: "#FFB300"}, // 󰜡 ".svh": {Icon: "\U000f035b", Color: "#FF7043"}, // 󰍛 ".swift": {Icon: "\U000f06e5", Color: "#FE5E2F"}, // 󰛥 ".t": {Icon: "\ue769", Color: "#519ABA"}, //  ".tar": {Icon: "\uf410", Color: "#ECA517"}, //  ".taz": {Icon: "\uf410", Color: "#ECA517"}, //  ".tbc": {Icon: "\U000f06d3", Color: "#005CA5"}, // 󰛓 ".tbz": {Icon: "\uf410", Color: "#ECA517"}, //  ".tbz2": {Icon: "\uf410", Color: "#ECA517"}, //  ".tcl": {Icon: "\U000f06d3", Color: "#EF5351"}, // 󰛓 ".templ": {Icon: "\U000f05c0", Color: "#FFD550"}, // 󰗀 ".terminal": {Icon: "\uf489", Color: "#14BA19"}, //  ".test.js": {Icon: "\uf499", Color: "#FFCA29"}, //  ".test.jsx": {Icon: "\uf499", Color: "#FFCA29"}, //  ".test.ts": {Icon: "\uf499", Color: "#519ABA"}, //  ".test.tsx": {Icon: "\uf499", Color: "#0188D1"}, //  ".tex": {Icon: "\ue69b", Color: "#42A5F5"}, //  ".tf": {Icon: "\ue69a", Color: "#5D6BC0"}, //  ".tfvars": {Icon: "\ue69a", Color: "#5D6BC0"}, //  ".tgz": {Icon: "\uf410", Color: "#ECA517"}, //  ".tiff": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".tlz": {Icon: "\uf410", Color: "#ECA517"}, //  ".tmux": {Icon: "\uebc8", Color: "#14BA19"}, //  ".toml": {Icon: "\ue6b2", Color: "#9C4221"}, //  ".torrent": {Icon: "\ue275", Color: "#4C90E8"}, //  ".tres": {Icon: "\ue65f", Color: "#42A5F5"}, //  ".ts": {Icon: "\U000f06e6", Color: "#0188D1"}, // 󰛦 ".tscn": {Icon: "\ue65f", Color: "#42A5F5"}, //  ".tsconfig": {Icon: "\ue772", Color: "#EA8220"}, //  ".tsv": {Icon: "\U000f021b", Color: "#8BC34A"}, // 󰈛 ".tsx": {Icon: "\ued46", Color: "#04BCD4"}, //  ".ttf": {Icon: "\ue659", Color: "#F54436"}, //  ".twig": {Icon: "\ue61c", Color: "#9BB92F"}, //  ".txt": {Icon: "\U000f0219", Color: "#42A5F5"}, // 󰈙 ".txz": {Icon: "\uf410", Color: "#ECA517"}, //  ".typ": {Icon: "\uf37f", Color: "#0DBCC0"}, //  ".typoscript": {Icon: "\ue772", Color: "#EA8220"}, //  ".tz": {Icon: "\uf410", Color: "#ECA517"}, //  ".tzo": {Icon: "\uf410", Color: "#ECA517"}, //  ".ui": {Icon: "\uf2d0", Color: "#015BF0"}, //  ".v": {Icon: "\ue6ac", Color: "#009CE5"}, //  ".vala": {Icon: "\ue8d1", Color: "#7B3DB9"}, //  ".vh": {Icon: "\U000f035b", Color: "#009900"}, // 󰍛 ".vhd": {Icon: "\U000f035b", Color: "#FF7043"}, // 󰍛 ".vhdl": {Icon: "\U000f035b", Color: "#009900"}, // 󰍛 ".video": {Icon: "\uf03d", Color: "#626262"}, //  ".vi": {Icon: "\ue81e", Color: "#FEC60A"}, //  ".vim": {Icon: "\ue62b", Color: "#44A047"}, //  ".vsh": {Icon: "\ue6ac", Color: "#5D87BF"}, //  ".vsix": {Icon: "\U000f0a1e", Color: "#2296F3"}, // 󰨞 ".vue": {Icon: "\ue6a0", Color: "#40B883"}, //  ".war": {Icon: "\ue256", Color: "#F54436"}, //  ".wasm": {Icon: "\ue6a1", Color: "#7D4DFF"}, //  ".wav": {Icon: "\U000f0386", Color: "#76B900"}, // 󰎆 ".webm": {Icon: "\U000f0381", Color: "#FF9800"}, // 󰎁 ".webmanifest": {Icon: "\ue60b", Color: "#CBCB41"}, //  ".webp": {Icon: "\U000f021f", Color: "#25A6A0"}, // 󰈟 ".webpack": {Icon: "\U000f072b", Color: "#519ABA"}, // 󰜫 ".windows": {Icon: "\uf17a", Color: "#00A4EF"}, //  ".wma": {Icon: "\U000f0386", Color: "#EE534F"}, // 󰎆 ".woff": {Icon: "\ue659", Color: "#F54436"}, //  ".woff2": {Icon: "\ue659", Color: "#F54436"}, //  ".wrl": {Icon: "\U000f01a7", Color: "#778899"}, // 󰆧 ".wrz": {Icon: "\U000f01a7", Color: "#778899"}, // 󰆧 ".wv": {Icon: "\uf001", Color: "#00AFFF"}, //  ".wvc": {Icon: "\uf001", Color: "#00AFFF"}, //  ".x": {Icon: "\ue691", Color: "#599EFF"}, //  ".xaml": {Icon: "\U000f0673", Color: "#42A5F5"}, // 󰙳 ".xcf": {Icon: "\uf338", Color: "#635b46"}, //  ".xcplayground": {Icon: "\ue755", Color: "#DC682E"}, //  ".xcstrings": {Icon: "\U000f05ca", Color: "#2596BE"}, // 󰗊 ".xhtml": {Icon: "\uf13b", Color: "#E44E27"}, //  ".xls": {Icon: "\U000f021b", Color: "#8BC34A"}, // 󰈛 ".xlsx": {Icon: "\U000f021b", Color: "#8BC34A"}, // 󰈛 ".xm": {Icon: "\ue691", Color: "#519ABA"}, //  ".xml": {Icon: "\U000f022e", Color: "#8BC34A"}, // 󰈮 ".xpi": {Icon: "\ueae6", Color: "#375A8E"}, //  ".xul": {Icon: "\uf121", Color: "#DC682E"}, //  ".xz": {Icon: "\uf410", Color: "#ECA517"}, //  ".yaml": {Icon: "\ue6a8", Color: "#a074b3"}, //  ".yml": {Icon: "\ue6a8", Color: "#a074b3"}, //  ".zig": {Icon: "\ue6a9", Color: "#FAA825"}, //  ".zip": {Icon: "\uf410", Color: "#ECA517"}, //  ".zsh": {Icon: "\U000f018d", Color: "#FF7043"}, // 󰆍 ".zsh-theme": {Icon: "\ue795", Color: "#89E051"}, //  ".zshrc": {Icon: "\ue795", Color: "#89E051"}, //  ".zst": {Icon: "\uf410", Color: "#ECA517"}, //  } func patchFileIconsForNerdFontsV2() { extIconMap[".cs"] = IconProperties{Icon: "\uf81a", Color: "#FEDECA"} //  extIconMap[".csproj"] = IconProperties{Icon: "\uf81a", Color: "#AB48BC"} //  extIconMap[".csx"] = IconProperties{Icon: "\uf81a", Color: "#0188D1"} //  extIconMap[".license"] = IconProperties{Icon: "\uf718", Color: "#626262"} //  extIconMap[".node"] = IconProperties{Icon: "\uf898", Color: "#E8274B"} //  extIconMap[".rtf"] = IconProperties{Icon: "\uf718", Color: "#626262"} //  extIconMap[".vue"] = IconProperties{Icon: "\ufd42", Color: "#89e051"} // ﵂ } func IconForFile(name string, isSubmodule bool, isLinkedWorktree bool, isDirectory bool, customIconsConfig *config.CustomIconsConfig) IconProperties { base := filepath.Base(name) if icon, ok := customIconsConfig.Filenames[base]; ok { return IconProperties{Color: icon.Color, Icon: icon.Icon} } if icon, ok := nameIconMap[base]; ok { return icon } ext := strings.ToLower(filepath.Ext(name)) if icon, ok := customIconsConfig.Extensions[ext]; ok { return IconProperties{Color: icon.Color, Icon: icon.Icon} } if icon, ok := extIconMap[ext]; ok { return icon } if isSubmodule { return DEFAULT_SUBMODULE_ICON } else if isLinkedWorktree { return IconProperties{LINKED_WORKTREE_ICON, "#4E4E4E"} } else if isDirectory { return DEFAULT_DIRECTORY_ICON } return DEFAULT_FILE_ICON } lazygit-0.50.0+ds1/pkg/gui/presentation/icons/file_icons_test.go000066400000000000000000000006411500612110400246240ustar00rootroot00000000000000package icons import ( "testing" ) func TestFileIcons(t *testing.T) { t.Run("TestFileIcons", func(t *testing.T) { for name, icon := range nameIconMap { if len([]rune(icon.Icon)) != 1 { t.Errorf("nameIconMap[\"%s\"] is not a single rune", name) } } for ext, icon := range extIconMap { if len([]rune(icon.Icon)) != 1 { t.Errorf("extIconMap[\"%s\"] is not a single rune", ext) } } }) } lazygit-0.50.0+ds1/pkg/gui/presentation/icons/git_icons.go000066400000000000000000000047101500612110400234320ustar00rootroot00000000000000package icons import ( "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" ) var ( BRANCH_ICON = "\U000f062c" // 󰘬 DETACHED_HEAD_ICON = "\ue729" //  TAG_ICON = "\uf02b" //  COMMIT_ICON = "\U000f0718" // 󰜘 MERGE_COMMIT_ICON = "\U000f062d" // 󰘭 DEFAULT_REMOTE_ICON = "\uf02a2" // 󰊢 STASH_ICON = "\uf01c" //  LINKED_WORKTREE_ICON = "\U000f0339" // 󰌹 MISSING_LINKED_WORKTREE_ICON = "\U000f033a" // 󰌺 ) var remoteIcons = map[string]string{ "github.com": "\ue709", //  "bitbucket.org": "\ue703", //  "gitlab.com": "\uf296", //  "dev.azure.com": "\U000f0805", // 󰠅 "codeberg.org": "\uf330", //  "git.FreeBSD.org": "\uf30c", //  "gitlab.archlinux.org": "\uf303", //  "gitlab.freedesktop.org": "\uf360", //  "gitlab.gnome.org": "\uf361", //  "gnu.org": "\ue779", //  "invent.kde.org": "\uf373", //  "kernel.org": "\uf31a", //  "salsa.debian.org": "\uf306", //  "sr.ht": "\uf1db", //  } func patchGitIconsForNerdFontsV2() { BRANCH_ICON = "\ufb2b" // שׂ COMMIT_ICON = "\ufc16" // ﰖ MERGE_COMMIT_ICON = "\ufb2c" // שּׁ DEFAULT_REMOTE_ICON = "\uf7a1" //  LINKED_WORKTREE_ICON = "\uf838" //  MISSING_LINKED_WORKTREE_ICON = "\uf839" //  remoteIcons["dev.azure.com"] = "\ufd03" // ﴃ } func IconForBranch(branch *models.Branch) string { if branch.DetachedHead { return DETACHED_HEAD_ICON } return BRANCH_ICON } func IconForRemoteBranch(branch *models.RemoteBranch) string { return BRANCH_ICON } func IconForTag(tag *models.Tag) string { return TAG_ICON } func IconForCommit(commit *models.Commit) string { if commit.IsMerge() { return MERGE_COMMIT_ICON } return COMMIT_ICON } func IconForRemote(remote *models.Remote) string { for domain, icon := range remoteIcons { for _, url := range remote.Urls { if strings.Contains(url, domain) { return icon } } } return DEFAULT_REMOTE_ICON } func IconForStash(stash *models.StashEntry) string { return STASH_ICON } func IconForWorktree(missing bool) string { if missing { return MISSING_LINKED_WORKTREE_ICON } return LINKED_WORKTREE_ICON } lazygit-0.50.0+ds1/pkg/gui/presentation/icons/icons.go000066400000000000000000000010211500612110400225570ustar00rootroot00000000000000package icons import ( "log" "github.com/samber/lo" ) type IconProperties struct { Icon string Color string } var isIconEnabled = false func IsIconEnabled() bool { return isIconEnabled } func SetNerdFontsVersion(version string) { if version == "" { isIconEnabled = false } else { if !lo.Contains([]string{"2", "3"}, version) { log.Fatalf("Unsupported nerdFontVersion %s", version) } if version == "2" { patchGitIconsForNerdFontsV2() patchFileIconsForNerdFontsV2() } isIconEnabled = true } } lazygit-0.50.0+ds1/pkg/gui/presentation/item_operations.go000066400000000000000000000012611500612110400235400ustar00rootroot00000000000000package presentation import ( "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" ) func ItemOperationToString(itemOperation types.ItemOperation, tr *i18n.TranslationSet) string { switch itemOperation { case types.ItemOperationNone: return "" case types.ItemOperationPushing: return tr.PushingStatus case types.ItemOperationPulling: return tr.PullingStatus case types.ItemOperationFastForwarding: return tr.FastForwarding case types.ItemOperationDeleting: return tr.DeletingStatus case types.ItemOperationFetching: return tr.FetchingStatus case types.ItemOperationCheckingOut: return tr.CheckingOutStatus } return "" } lazygit-0.50.0+ds1/pkg/gui/presentation/reflog_commits.go000066400000000000000000000045731500612110400233610ustar00rootroot00000000000000package presentation import ( "time" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/kyokomi/emoji/v2" "github.com/samber/lo" ) func GetReflogCommitListDisplayStrings(commits []*models.Commit, fullDescription bool, cherryPickedCommitHashSet *set.Set[string], diffName string, now time.Time, timeFormat string, shortTimeFormat string, parseEmoji bool) [][]string { var displayFunc func(*models.Commit, reflogCommitDisplayAttributes) []string if fullDescription { displayFunc = getFullDescriptionDisplayStringsForReflogCommit } else { displayFunc = getDisplayStringsForReflogCommit } return lo.Map(commits, func(commit *models.Commit, _ int) []string { diffed := commit.Hash() == diffName cherryPicked := cherryPickedCommitHashSet.Includes(commit.Hash()) return displayFunc(commit, reflogCommitDisplayAttributes{ cherryPicked: cherryPicked, diffed: diffed, parseEmoji: parseEmoji, timeFormat: timeFormat, shortTimeFormat: shortTimeFormat, now: now, }) }) } func reflogHashColor(cherryPicked, diffed bool) style.TextStyle { if diffed { return theme.DiffTerminalColor } hashColor := style.FgBlue if cherryPicked { hashColor = theme.CherryPickedCommitTextStyle } return hashColor } type reflogCommitDisplayAttributes struct { cherryPicked bool diffed bool parseEmoji bool timeFormat string shortTimeFormat string now time.Time } func getFullDescriptionDisplayStringsForReflogCommit(c *models.Commit, attrs reflogCommitDisplayAttributes) []string { name := c.Name if attrs.parseEmoji { name = emoji.Sprint(name) } return []string{ reflogHashColor(attrs.cherryPicked, attrs.diffed).Sprint(c.ShortHash()), style.FgMagenta.Sprint(utils.UnixToDateSmart(attrs.now, c.UnixTimestamp, attrs.timeFormat, attrs.shortTimeFormat)), theme.DefaultTextColor.Sprint(name), } } func getDisplayStringsForReflogCommit(c *models.Commit, attrs reflogCommitDisplayAttributes) []string { name := c.Name if attrs.parseEmoji { name = emoji.Sprint(name) } return []string{ reflogHashColor(attrs.cherryPicked, attrs.diffed).Sprint(c.ShortHash()), theme.DefaultTextColor.Sprint(name), } } lazygit-0.50.0+ds1/pkg/gui/presentation/remote_branches.go000066400000000000000000000016561500612110400235070ustar00rootroot00000000000000package presentation import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" ) func GetRemoteBranchListDisplayStrings(branches []*models.RemoteBranch, diffName string) [][]string { return lo.Map(branches, func(branch *models.RemoteBranch, _ int) []string { diffed := branch.FullName() == diffName return getRemoteBranchDisplayStrings(branch, diffed) }) } // getRemoteBranchDisplayStrings returns the display string of branch func getRemoteBranchDisplayStrings(b *models.RemoteBranch, diffed bool) []string { textStyle := GetBranchTextStyle(b.Name) if diffed { textStyle = theme.DiffTerminalColor } res := make([]string, 0, 2) if icons.IsIconEnabled() { res = append(res, textStyle.Sprint(icons.IconForRemoteBranch(b))) } res = append(res, textStyle.Sprint(b.Name)) return res } lazygit-0.50.0+ds1/pkg/gui/presentation/remotes.go000066400000000000000000000032741500612110400220230ustar00rootroot00000000000000package presentation import ( "time" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) func GetRemoteListDisplayStrings( remotes []*models.Remote, diffName string, getItemOperation func(item types.HasUrn) types.ItemOperation, tr *i18n.TranslationSet, userConfig *config.UserConfig, ) [][]string { return lo.Map(remotes, func(remote *models.Remote, _ int) []string { diffed := remote.Name == diffName return getRemoteDisplayStrings(remote, diffed, getItemOperation(remote), tr, userConfig) }) } // getRemoteDisplayStrings returns the display string of branch func getRemoteDisplayStrings( r *models.Remote, diffed bool, itemOperation types.ItemOperation, tr *i18n.TranslationSet, userConfig *config.UserConfig, ) []string { branchCount := len(r.Branches) textStyle := theme.DefaultTextColor if diffed { textStyle = theme.DiffTerminalColor } res := make([]string, 0, 3) if icons.IsIconEnabled() { res = append(res, textStyle.Sprint(icons.IconForRemote(r))) } descriptionStr := style.FgBlue.Sprintf("%d branches", branchCount) itemOperationStr := ItemOperationToString(itemOperation, tr) if itemOperationStr != "" { descriptionStr += " " + style.FgCyan.Sprint(itemOperationStr+" "+utils.Loader(time.Now(), userConfig.Gui.Spinner)) } res = append(res, textStyle.Sprint(r.Name), descriptionStr) return res } lazygit-0.50.0+ds1/pkg/gui/presentation/stash_entries.go000066400000000000000000000020171500612110400232120ustar00rootroot00000000000000package presentation import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" ) func GetStashEntryListDisplayStrings(stashEntries []*models.StashEntry, diffName string) [][]string { return lo.Map(stashEntries, func(stashEntry *models.StashEntry, _ int) []string { diffed := stashEntry.RefName() == diffName return getStashEntryDisplayStrings(stashEntry, diffed) }) } // getStashEntryDisplayStrings returns the display string of branch func getStashEntryDisplayStrings(s *models.StashEntry, diffed bool) []string { textStyle := theme.DefaultTextColor if diffed { textStyle = theme.DiffTerminalColor } res := make([]string, 0, 3) res = append(res, style.FgCyan.Sprint(s.Recency)) if icons.IsIconEnabled() { res = append(res, textStyle.Sprint(icons.IconForStash(s))) } res = append(res, textStyle.Sprint(s.Name)) return res } lazygit-0.50.0+ds1/pkg/gui/presentation/status.go000066400000000000000000000025031500612110400216620ustar00rootroot00000000000000package presentation import ( "fmt" "time" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" ) func FormatStatus( repoName string, currentBranch *models.Branch, itemOperation types.ItemOperation, linkedWorktreeName string, workingTreeState models.WorkingTreeState, tr *i18n.TranslationSet, userConfig *config.UserConfig, ) string { status := "" if currentBranch.IsRealBranch() { status += BranchStatus(currentBranch, itemOperation, tr, time.Now(), userConfig) if status != "" { status += " " } } if workingTreeState.Any() { status += style.FgYellow.Sprintf("(%s) ", workingTreeState.LowerCaseTitle(tr)) } name := GetBranchTextStyle(currentBranch.Name).Sprint(currentBranch.Name) // If the user is in a linked worktree (i.e. not the main worktree) we'll display that if linkedWorktreeName != "" { icon := "" if icons.IsIconEnabled() { icon = icons.LINKED_WORKTREE_ICON + " " } repoName = fmt.Sprintf("%s(%s%s)", repoName, icon, style.FgCyan.Sprint(linkedWorktreeName)) } status += fmt.Sprintf("%s → %s", repoName, name) return status } lazygit-0.50.0+ds1/pkg/gui/presentation/submodules.go000066400000000000000000000012771500612110400225300ustar00rootroot00000000000000package presentation import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" ) func GetSubmoduleListDisplayStrings(submodules []*models.SubmoduleConfig) [][]string { return lo.Map(submodules, func(submodule *models.SubmoduleConfig, _ int) []string { return getSubmoduleDisplayStrings(submodule) }) } func getSubmoduleDisplayStrings(s *models.SubmoduleConfig) []string { name := s.Name if s.ParentModule != nil { indentation := "" for p := s.ParentModule; p != nil; p = p.ParentModule { indentation += " " } name = indentation + "- " + s.Name } return []string{theme.DefaultTextColor.Sprint(name)} } lazygit-0.50.0+ds1/pkg/gui/presentation/suggestions.go000066400000000000000000000006671500612110400227220ustar00rootroot00000000000000package presentation import ( "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) func GetSuggestionListDisplayStrings(suggestions []*types.Suggestion) [][]string { return lo.Map(suggestions, func(suggestion *types.Suggestion, _ int) []string { return getSuggestionDisplayStrings(suggestion) }) } func getSuggestionDisplayStrings(suggestion *types.Suggestion) []string { return []string{suggestion.Label} } lazygit-0.50.0+ds1/pkg/gui/presentation/tags.go000066400000000000000000000032341500612110400212770ustar00rootroot00000000000000package presentation import ( "time" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) func GetTagListDisplayStrings( tags []*models.Tag, getItemOperation func(item types.HasUrn) types.ItemOperation, diffName string, tr *i18n.TranslationSet, userConfig *config.UserConfig, ) [][]string { return lo.Map(tags, func(tag *models.Tag, _ int) []string { diffed := tag.Name == diffName return getTagDisplayStrings(tag, getItemOperation(tag), diffed, tr, userConfig) }) } // getTagDisplayStrings returns the display string of branch func getTagDisplayStrings( t *models.Tag, itemOperation types.ItemOperation, diffed bool, tr *i18n.TranslationSet, userConfig *config.UserConfig, ) []string { textStyle := theme.DefaultTextColor if diffed { textStyle = theme.DiffTerminalColor } res := make([]string, 0, 2) if icons.IsIconEnabled() { res = append(res, textStyle.Sprint(icons.IconForTag(t))) } descriptionColor := style.FgYellow descriptionStr := descriptionColor.Sprint(t.Description()) itemOperationStr := ItemOperationToString(itemOperation, tr) if itemOperationStr != "" { descriptionStr = style.FgCyan.Sprint(itemOperationStr+" "+utils.Loader(time.Now(), userConfig.Gui.Spinner)) + " " + descriptionStr } res = append(res, textStyle.Sprint(t.Name), descriptionStr) return res } lazygit-0.50.0+ds1/pkg/gui/presentation/worktrees.go000066400000000000000000000024451500612110400223710ustar00rootroot00000000000000package presentation import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" ) func GetWorktreeDisplayStrings(tr *i18n.TranslationSet, worktrees []*models.Worktree) [][]string { return lo.Map(worktrees, func(worktree *models.Worktree, _ int) []string { return GetWorktreeDisplayString( tr, worktree) }) } func GetWorktreeDisplayString(tr *i18n.TranslationSet, worktree *models.Worktree) []string { textStyle := theme.DefaultTextColor current := "" currentColor := style.FgCyan if worktree.IsCurrent { current = " *" currentColor = style.FgGreen } icon := icons.IconForWorktree(false) if worktree.IsPathMissing { textStyle = style.FgRed icon = icons.IconForWorktree(true) } res := []string{} res = append(res, currentColor.Sprint(current)) if icons.IsIconEnabled() { res = append(res, textStyle.Sprint(icon)) } name := worktree.Name if worktree.IsMain { name += " " + tr.MainWorktree } if worktree.IsPathMissing && !icons.IsIconEnabled() { name += " " + tr.MissingWorktree } res = append(res, textStyle.Sprint(name)) return res } lazygit-0.50.0+ds1/pkg/gui/pty.go000066400000000000000000000072441500612110400164470ustar00rootroot00000000000000//go:build !windows // +build !windows package gui import ( "io" "os" "os/exec" "strings" "github.com/creack/pty" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) func (gui *Gui) desiredPtySize(view *gocui.View) *pty.Winsize { width, height := view.InnerSize() return &pty.Winsize{Cols: uint16(width), Rows: uint16(height)} } func (gui *Gui) onResize() error { gui.Mutexes.PtyMutex.Lock() defer gui.Mutexes.PtyMutex.Unlock() for viewName, ptmx := range gui.viewPtmxMap { // TODO: handle resizing properly: we need to actually clear the main view // and re-read the output from our pty. Or we could just re-run the original // command from scratch view, _ := gui.g.View(viewName) if err := pty.Setsize(ptmx, gui.desiredPtySize(view)); err != nil { return utils.WrapError(err) } } return nil } // Some commands need to output for a terminal to active certain behaviour. // For example, git won't invoke the GIT_PAGER env var unless it thinks it's // talking to a terminal. We typically write cmd outputs straight to a view, // which is just an io.Reader. the pty package lets us wrap a command in a // pseudo-terminal meaning we'll get the behaviour we want from the underlying // command. func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { width := view.InnerWidth() pager := gui.git.Config.GetPager(width) externalDiffCommand := gui.Config.GetUserConfig().Git.Paging.ExternalDiffCommand if pager == "" && externalDiffCommand == "" { // if we're not using a custom pager we don't need to use a pty return gui.newCmdTask(view, cmd, prefix) } // Run the pty after layout so that it gets the correct size gui.afterLayout(func() error { // Need to get the width and the pager again because the layout might have // changed the size of the view width = view.InnerWidth() pager = gui.git.Config.GetPager(width) cmdStr := strings.Join(cmd.Args, " ") // This communicates to pagers that we're in a very simple // terminal that they should not expect to have much capabilities. // Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities. // Context: https://github.com/jesseduffield/lazygit/issues/3419 cmd.Env = removeExistingTermEnvVars(cmd.Env) cmd.Env = append(cmd.Env, "TERM=dumb") cmd.Env = append(cmd.Env, "GIT_PAGER="+pager) manager := gui.getManager(view) var ptmx *os.File start := func() (*exec.Cmd, io.Reader) { var err error ptmx, err = pty.StartWithSize(cmd, gui.desiredPtySize(view)) if err != nil { gui.c.Log.Error(err) } gui.Mutexes.PtyMutex.Lock() gui.viewPtmxMap[view.Name()] = ptmx gui.Mutexes.PtyMutex.Unlock() return cmd, ptmx } onClose := func() { gui.Mutexes.PtyMutex.Lock() ptmx.Close() delete(gui.viewPtmxMap, view.Name()) gui.Mutexes.PtyMutex.Unlock() } linesToRead := gui.linesToReadFromCmdTask(view) return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr) }) return nil } func removeExistingTermEnvVars(env []string) []string { return lo.Filter(env, func(envVar string, _ int) bool { return !isTermEnvVar(envVar) }) } // Terminals set a variety of different environment variables // to identify themselves to processes. This list should catch the most common among them. func isTermEnvVar(envVar string) bool { return strings.HasPrefix(envVar, "TERM=") || strings.HasPrefix(envVar, "TERM_PROGRAM=") || strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") || strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") || strings.HasPrefix(envVar, "TERMINAL_NAME=") || strings.HasPrefix(envVar, "TERMINAL_VERSION_") } lazygit-0.50.0+ds1/pkg/gui/pty_windows.go000066400000000000000000000004361500612110400202150ustar00rootroot00000000000000//go:build windows // +build windows package gui import ( "os/exec" "github.com/jesseduffield/gocui" ) func (gui *Gui) onResize() error { return nil } func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { return gui.newCmdTask(view, cmd, prefix) } lazygit-0.50.0+ds1/pkg/gui/recent_repos_panel.go000066400000000000000000000024761500612110400215040ustar00rootroot00000000000000package gui import ( "os" "path/filepath" ) // updateRecentRepoList registers the fact that we opened lazygit in this repo, // so that we can open the same repo via the 'recent repos' menu func (gui *Gui) updateRecentRepoList() error { if gui.git.Status.IsBareRepo() { // we could totally do this but it would require storing both the git-dir and the // worktree in our recent repos list, which is a change that would need to be // backwards compatible gui.c.Log.Info("Not appending bare repo to recent repo list") return nil } recentRepos := gui.c.GetAppState().RecentRepos currentRepo, err := os.Getwd() if err != nil { return err } recentRepos = newRecentReposList(recentRepos, currentRepo) // TODO: migrate this file to use forward slashes on all OSes for consistency // (windows uses backslashes at the moment) gui.c.GetAppState().RecentRepos = recentRepos return gui.c.SaveAppState() } // newRecentReposList returns a new repo list with a new entry but only when it doesn't exist yet func newRecentReposList(recentRepos []string, currentRepo string) []string { newRepos := []string{currentRepo} for _, repo := range recentRepos { if repo != currentRepo { if _, err := os.Stat(filepath.Join(repo, ".git")); err != nil { continue } newRepos = append(newRepos, repo) } } return newRepos } lazygit-0.50.0+ds1/pkg/gui/services/000077500000000000000000000000001500612110400171205ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/000077500000000000000000000000001500612110400223135ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/client.go000066400000000000000000000073241500612110400241260ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" ) // Client is the entry point to this package. It returns a list of keybindings based on the config's user-defined custom commands. // See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md for more info. type Client struct { c *helpers.HelperCommon handlerCreator *HandlerCreator keybindingCreator *KeybindingCreator } func NewClient( c *helpers.HelperCommon, helpers *helpers.Helpers, ) *Client { sessionStateLoader := NewSessionStateLoader(c, helpers.Refs) handlerCreator := NewHandlerCreator( c, sessionStateLoader, helpers.Suggestions, helpers.MergeAndRebase, ) keybindingCreator := NewKeybindingCreator(c) return &Client{ c: c, keybindingCreator: keybindingCreator, handlerCreator: handlerCreator, } } func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) { bindings := []*types.Binding{} for _, customCommand := range self.c.UserConfig().CustomCommands { if len(customCommand.CommandMenu) > 0 { handler := func() error { return self.showCustomCommandsMenu(customCommand) } bindings = append(bindings, &types.Binding{ ViewName: "", // custom commands menus are global; we filter the commands inside by context Key: keybindings.GetKey(customCommand.Key), Modifier: gocui.ModNone, Handler: handler, Description: getCustomCommandsMenuDescription(customCommand, self.c.Tr), OpensMenu: true, }) } else { handler := self.handlerCreator.call(customCommand) compoundBindings, err := self.keybindingCreator.call(customCommand, handler) if err != nil { return nil, err } bindings = append(bindings, compoundBindings...) } } return bindings, nil } func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) error { menuItems := make([]*types.MenuItem, 0, len(customCommand.CommandMenu)) for _, subCommand := range customCommand.CommandMenu { if len(subCommand.CommandMenu) > 0 { handler := func() error { return self.showCustomCommandsMenu(subCommand) } menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), Key: keybindings.GetKey(subCommand.Key), OnPress: handler, OpensMenu: true, }) } else { if subCommand.Context != "" && subCommand.Context != "global" { viewNames, err := self.keybindingCreator.getViewNamesAndContexts(subCommand) if err != nil { return err } currentView := self.c.GocuiGui().CurrentView() enabled := currentView != nil && lo.Contains(viewNames, currentView.Name()) if !enabled { continue } } menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), Key: keybindings.GetKey(subCommand.Key), OnPress: self.handlerCreator.call(subCommand), }) } } if len(menuItems) == 0 { menuItems = append(menuItems, &types.MenuItem{ Label: self.c.Tr.NoApplicableCommandsInThisContext, OnPress: func() error { return nil }, }) } title := getCustomCommandsMenuDescription(customCommand, self.c.Tr) return self.c.Menu(types.CreateMenuOptions{Title: title, Items: menuItems, HideCancel: true}) } func getCustomCommandsMenuDescription(customCommand config.CustomCommand, tr *i18n.TranslationSet) string { if customCommand.Description != "" { return customCommand.Description } return tr.CustomCommands } lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/handler_creator.go000066400000000000000000000232421500612110400260010ustar00rootroot00000000000000package custom_commands import ( "errors" "fmt" "strings" "text/template" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // takes a custom command and returns a function that will be called when the corresponding user-defined keybinding is pressed type HandlerCreator struct { c *helpers.HelperCommon sessionStateLoader *SessionStateLoader resolver *Resolver menuGenerator *MenuGenerator suggestionsHelper *helpers.SuggestionsHelper mergeAndRebaseHelper *helpers.MergeAndRebaseHelper } func NewHandlerCreator( c *helpers.HelperCommon, sessionStateLoader *SessionStateLoader, suggestionsHelper *helpers.SuggestionsHelper, mergeAndRebaseHelper *helpers.MergeAndRebaseHelper, ) *HandlerCreator { resolver := NewResolver(c.Common) menuGenerator := NewMenuGenerator(c.Common) return &HandlerCreator{ c: c, sessionStateLoader: sessionStateLoader, resolver: resolver, menuGenerator: menuGenerator, suggestionsHelper: suggestionsHelper, mergeAndRebaseHelper: mergeAndRebaseHelper, } } func (self *HandlerCreator) call(customCommand config.CustomCommand) func() error { return func() error { sessionState := self.sessionStateLoader.call() promptResponses := make([]string, len(customCommand.Prompts)) form := make(map[string]string) f := func() error { return self.finalHandler(customCommand, sessionState, promptResponses, form) } // if we have prompts we'll recursively wrap our confirm handlers with more prompts // until we reach the actual command for reverseIdx := range customCommand.Prompts { // reassigning so that we don't end up with an infinite recursion g := f idx := len(customCommand.Prompts) - 1 - reverseIdx // going backwards so the outermost prompt is the first one prompt := customCommand.Prompts[idx] wrappedF := func(response string) error { promptResponses[idx] = response form[prompt.Key] = response return g() } resolveTemplate := self.getResolveTemplateFn(form, promptResponses, sessionState) switch prompt.Type { case "input": f = func() error { resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) if err != nil { return err } return self.inputPrompt(resolvedPrompt, wrappedF) } case "menu": f = func() error { resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) if err != nil { return err } return self.menuPrompt(resolvedPrompt, wrappedF) } case "menuFromCommand": f = func() error { resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) if err != nil { return err } return self.menuPromptFromCommand(resolvedPrompt, wrappedF) } case "confirm": f = func() error { resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate) if err != nil { return err } return self.confirmPrompt(resolvedPrompt, g) } default: return errors.New("custom command prompt must have a type of 'input', 'menu', 'menuFromCommand', or 'confirm'") } } return f() } } func (self *HandlerCreator) inputPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { findSuggestionsFn, err := self.generateFindSuggestionsFunc(prompt) if err != nil { return err } self.c.Prompt(types.PromptOpts{ Title: prompt.Title, InitialContent: prompt.InitialValue, FindSuggestionsFunc: findSuggestionsFn, HandleConfirm: func(str string) error { return wrappedF(str) }, }) return nil } func (self *HandlerCreator) generateFindSuggestionsFunc(prompt *config.CustomCommandPrompt) (func(string) []*types.Suggestion, error) { if prompt.Suggestions.Preset != "" && prompt.Suggestions.Command != "" { return nil, fmt.Errorf( "Custom command prompt cannot have both a preset and a command for suggestions. Preset: '%s', Command: '%s'", prompt.Suggestions.Preset, prompt.Suggestions.Command, ) } else if prompt.Suggestions.Preset != "" { return self.getPresetSuggestionsFn(prompt.Suggestions.Preset) } else if prompt.Suggestions.Command != "" { return self.getCommandSuggestionsFn(prompt.Suggestions.Command) } return nil, nil } func (self *HandlerCreator) getCommandSuggestionsFn(command string) (func(string) []*types.Suggestion, error) { lines := []*types.Suggestion{} err := self.c.OS().Cmd.NewShell(command, self.c.UserConfig().OS.ShellFunctionsFile).RunAndProcessLines(func(line string) (bool, error) { lines = append(lines, &types.Suggestion{Value: line, Label: line}) return false, nil }) if err != nil { return nil, err } return func(currentWord string) []*types.Suggestion { return lo.Filter(lines, func(suggestion *types.Suggestion, _ int) bool { return strings.Contains(strings.ToLower(suggestion.Value), strings.ToLower(currentWord)) }) }, nil } func (self *HandlerCreator) getPresetSuggestionsFn(preset string) (func(string) []*types.Suggestion, error) { switch preset { case "authors": return self.suggestionsHelper.GetAuthorsSuggestionsFunc(), nil case "branches": return self.suggestionsHelper.GetBranchNameSuggestionsFunc(), nil case "files": return self.suggestionsHelper.GetFilePathSuggestionsFunc(), nil case "refs": return self.suggestionsHelper.GetRefsSuggestionsFunc(), nil case "remotes": return self.suggestionsHelper.GetRemoteSuggestionsFunc(), nil case "remoteBranches": return self.suggestionsHelper.GetRemoteBranchesSuggestionsFunc("/"), nil case "tags": return self.suggestionsHelper.GetTagsSuggestionsFunc(), nil default: return nil, fmt.Errorf("Unknown value for suggestionsPreset in custom command: %s. Valid values: files, branches, remotes, remoteBranches, refs", preset) } } func (self *HandlerCreator) confirmPrompt(prompt *config.CustomCommandPrompt, handleConfirm func() error) error { self.c.Confirm(types.ConfirmOpts{ Title: prompt.Title, Prompt: prompt.Body, HandleConfirm: handleConfirm, }) return nil } func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { menuItems := lo.Map(prompt.Options, func(option config.CustomCommandMenuOption, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{option.Name, style.FgYellow.Sprint(option.Description)}, OnPress: func() error { return wrappedF(option.Value) }, } }) return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems}) } func (self *HandlerCreator) menuPromptFromCommand(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error { // Run and save output message, err := self.c.Git().Custom.RunWithOutput(prompt.Command) if err != nil { return err } // Need to make a menu out of what the cmd has displayed candidates, err := self.menuGenerator.call(message, prompt.Filter, prompt.ValueFormat, prompt.LabelFormat) if err != nil { return err } menuItems := lo.Map(candidates, func(candidate *commandMenuItem, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{candidate.label}, OnPress: func() error { return wrappedF(candidate.value) }, } }) return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems}) } type CustomCommandObjects struct { *SessionState PromptResponses []string Form map[string]string } func (self *HandlerCreator) getResolveTemplateFn(form map[string]string, promptResponses []string, sessionState *SessionState) func(string) (string, error) { objects := CustomCommandObjects{ SessionState: sessionState, PromptResponses: promptResponses, Form: form, } funcs := template.FuncMap{ "quote": self.c.OS().Quote, "runCommand": self.c.Git().Custom.TemplateFunctionRunCommand, } return func(templateStr string) (string, error) { return utils.ResolveTemplate(templateStr, objects, funcs) } } func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, sessionState *SessionState, promptResponses []string, form map[string]string) error { resolveTemplate := self.getResolveTemplateFn(form, promptResponses, sessionState) cmdStr, err := resolveTemplate(customCommand.Command) if err != nil { return err } cmdObj := self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile) if customCommand.Subprocess != nil && *customCommand.Subprocess { return self.c.RunSubprocessAndRefresh(cmdObj) } loadingText := customCommand.LoadingText if loadingText == "" { loadingText = self.c.Tr.RunningCustomCommandStatus } return self.c.WithWaitingStatus(loadingText, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.CustomCommand) if customCommand.Stream != nil && *customCommand.Stream { cmdObj.StreamOutput() } output, err := cmdObj.RunWithOutput() if refreshErr := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil { self.c.Log.Error(refreshErr) } if err != nil { if customCommand.After != nil && customCommand.After.CheckForConflicts { return self.mergeAndRebaseHelper.CheckForConflicts(err) } return err } if customCommand.ShowOutput != nil && *customCommand.ShowOutput { if strings.TrimSpace(output) == "" { output = self.c.Tr.EmptyOutput } title := cmdStr if customCommand.OutputTitle != "" { title, err = resolveTemplate(customCommand.OutputTitle) if err != nil { return err } } self.c.Alert(title, output) } return nil }) } lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/keybinding_creator.go000066400000000000000000000055101500612110400265050ustar00rootroot00000000000000package custom_commands import ( "fmt" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) // KeybindingCreator takes a custom command along with its handler and returns a corresponding keybinding type KeybindingCreator struct { c *helpers.HelperCommon } func NewKeybindingCreator(c *helpers.HelperCommon) *KeybindingCreator { return &KeybindingCreator{ c: c, } } func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler func() error) ([]*types.Binding, error) { if customCommand.Context == "" { return nil, formatContextNotProvidedError(customCommand) } viewNames, err := self.getViewNamesAndContexts(customCommand) if err != nil { return nil, err } return lo.Map(viewNames, func(viewName string, _ int) *types.Binding { return &types.Binding{ ViewName: viewName, Key: keybindings.GetKey(customCommand.Key), Modifier: gocui.ModNone, Handler: handler, Description: customCommand.GetDescription(), } }), nil } func (self *KeybindingCreator) getViewNamesAndContexts(customCommand config.CustomCommand) ([]string, error) { if customCommand.Context == "global" { return []string{""}, nil } contexts := strings.Split(customCommand.Context, ",") contexts = lo.Map(contexts, func(context string, _ int) string { return strings.TrimSpace(context) }) viewNames := []string{} for _, context := range contexts { ctx, ok := self.contextForContextKey(types.ContextKey(context)) if !ok { return []string{}, formatUnknownContextError(customCommand) } viewNames = append(viewNames, ctx.GetViewName()) } return viewNames, nil } func (self *KeybindingCreator) contextForContextKey(contextKey types.ContextKey) (types.Context, bool) { for _, context := range self.c.Contexts().Flatten() { if context.GetKey() == contextKey { return context, true } } return nil, false } func formatUnknownContextError(customCommand config.CustomCommand) error { allContextKeyStrings := lo.Map(context.AllContextKeys, func(key types.ContextKey, _ int) string { return string(key) }) return fmt.Errorf("Error when setting custom command keybindings: unknown context: %s. Key: %s, Command: %s.\nPermitted contexts: %s", customCommand.Context, customCommand.Key, customCommand.Command, strings.Join(allContextKeyStrings, ", ")) } func formatContextNotProvidedError(customCommand config.CustomCommand) error { return fmt.Errorf("Error parsing custom command keybindings: context not provided (use context: 'global' for the global context). Key: %s, Command: %s", customCommand.Key, customCommand.Command) } lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/menu_generator.go000066400000000000000000000076031500612110400256620ustar00rootroot00000000000000package custom_commands import ( "bytes" "errors" "regexp" "strconv" "strings" "text/template" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/style" ) type MenuGenerator struct { c *common.Common } // takes the output of a command and returns a list of menu entries based on a filter // and value/label format templates provided by the user func NewMenuGenerator(c *common.Common) *MenuGenerator { return &MenuGenerator{c: c} } type commandMenuItem struct { label string value string } func (self *MenuGenerator) call(commandOutput, filter, valueFormat, labelFormat string) ([]*commandMenuItem, error) { menuItemFromLine, err := self.getMenuItemFromLinefn(filter, valueFormat, labelFormat) if err != nil { return nil, err } menuItems := []*commandMenuItem{} for _, line := range strings.Split(commandOutput, "\n") { if line == "" { continue } menuItem, err := menuItemFromLine(line) if err != nil { return nil, err } menuItems = append(menuItems, menuItem) } return menuItems, nil } func (self *MenuGenerator) getMenuItemFromLinefn(filter string, valueFormat string, labelFormat string) (func(line string) (*commandMenuItem, error), error) { if filter == "" && valueFormat == "" && labelFormat == "" { // showing command output lines as-is in suggestions panel return func(line string) (*commandMenuItem, error) { return &commandMenuItem{label: line, value: line}, nil }, nil } regex, err := regexp.Compile(filter) if err != nil { return nil, errors.New("unable to parse filter regex, error: " + err.Error()) } valueTemplateAux, err := template.New("format").Parse(valueFormat) if err != nil { return nil, errors.New("unable to parse value format, error: " + err.Error()) } valueTemplate := NewTrimmerTemplate(valueTemplateAux) var labelTemplate *TrimmerTemplate if labelFormat != "" { colorFuncMap := style.TemplateFuncMapAddColors(template.FuncMap{}) labelTemplateAux, err := template.New("format").Funcs(colorFuncMap).Parse(labelFormat) if err != nil { return nil, errors.New("unable to parse label format, error: " + err.Error()) } labelTemplate = NewTrimmerTemplate(labelTemplateAux) } else { labelTemplate = valueTemplate } return func(line string) (*commandMenuItem, error) { return self.generateMenuItem( line, regex, valueTemplate, labelTemplate, ) }, nil } func (self *MenuGenerator) generateMenuItem( line string, regex *regexp.Regexp, valueTemplate *TrimmerTemplate, labelTemplate *TrimmerTemplate, ) (*commandMenuItem, error) { tmplData := self.parseLine(line, regex) entry := &commandMenuItem{} var err error entry.value, err = valueTemplate.execute(tmplData) if err != nil { return nil, err } entry.label, err = labelTemplate.execute(tmplData) if err != nil { return nil, err } return entry, nil } func (self *MenuGenerator) parseLine(line string, regex *regexp.Regexp) map[string]string { tmplData := map[string]string{} out := regex.FindAllStringSubmatch(line, -1) if len(out) > 0 { for groupIdx, group := range regex.SubexpNames() { // Record matched group with group ids matchName := "group_" + strconv.Itoa(groupIdx) tmplData[matchName] = out[0][groupIdx] // Record last named group non-empty matches as group matches if group != "" { tmplData[group] = out[0][groupIdx] } } } return tmplData } // wrapper around a template which trims the output type TrimmerTemplate struct { template *template.Template buffer *bytes.Buffer } func NewTrimmerTemplate(template *template.Template) *TrimmerTemplate { return &TrimmerTemplate{ template: template, buffer: bytes.NewBuffer(nil), } } func (self *TrimmerTemplate) execute(tmplData map[string]string) (string, error) { self.buffer.Reset() err := self.template.Execute(self.buffer, tmplData) if err != nil { return "", err } return strings.TrimSpace(self.buffer.String()), nil } lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/menu_generator_test.go000066400000000000000000000044451500612110400267220ustar00rootroot00000000000000package custom_commands import ( "testing" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) func TestMenuGenerator(t *testing.T) { type scenario struct { testName string cmdOut string filter string valueFormat string labelFormat string test func([]*commandMenuItem, error) } scenarios := []scenario{ { "Extract remote branch name", "upstream/pr-1", "(?P[a-z_]+)/(?P.*)", "{{ .branch }}", "Remote: {{ .remote }}", func(actualEntry []*commandMenuItem, err error) { assert.NoError(t, err) assert.EqualValues(t, "pr-1", actualEntry[0].value) assert.EqualValues(t, "Remote: upstream", actualEntry[0].label) }, }, { "Multiple named groups with empty labelFormat", "upstream/pr-1", "(?P[a-z]*)/(?P.*)", "{{ .branch }}|{{ .remote }}", "", func(actualEntry []*commandMenuItem, err error) { assert.NoError(t, err) assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value) assert.EqualValues(t, "pr-1|upstream", actualEntry[0].label) }, }, { "Multiple named groups with group ids", "upstream/pr-1", "(?P[a-z]*)/(?P.*)", "{{ .group_2 }}|{{ .group_1 }}", "Remote: {{ .group_1 }}", func(actualEntry []*commandMenuItem, err error) { assert.NoError(t, err) assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value) assert.EqualValues(t, "Remote: upstream", actualEntry[0].label) }, }, { "No named groups", "upstream/pr-1", "([a-z]*)/(.*)", "{{ .group_2 }}|{{ .group_1 }}", "Remote: {{ .group_1 }}", func(actualEntry []*commandMenuItem, err error) { assert.NoError(t, err) assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value) assert.EqualValues(t, "Remote: upstream", actualEntry[0].label) }, }, { "No filter", "upstream/pr-1", "", "", "", func(actualEntry []*commandMenuItem, err error) { assert.NoError(t, err) assert.EqualValues(t, "upstream/pr-1", actualEntry[0].value) assert.EqualValues(t, "upstream/pr-1", actualEntry[0].label) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { s.test(NewMenuGenerator(utils.NewDummyCommon()).call(s.cmdOut, s.filter, s.valueFormat, s.labelFormat)) }) } } lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/models.go000066400000000000000000000045411500612110400241310ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/stefanhaller/git-todo-parser/todo" ) // We create shims for all the model classes in order to get a more stable API // for custom commands. At the moment these are almost identical to the model // classes, but this allows us to add "private" fields to the model classes that // we don't want to expose to custom commands, or rename a model field to a // better name without breaking people's custom commands. In such a case we add // the new, better name to the shim but keep the old one for backwards // compatibility. We already did this for Commit.Sha, which was renamed to Hash. type Commit struct { Hash string Sha string // deprecated: use Hash Name string Status models.CommitStatus Action todo.TodoCommand Tags []string ExtraInfo string AuthorName string AuthorEmail string UnixTimestamp int64 Divergence models.Divergence Parents []string } type File struct { Name string PreviousName string HasStagedChanges bool HasUnstagedChanges bool Tracked bool Added bool Deleted bool HasMergeConflicts bool HasInlineMergeConflicts bool DisplayString string ShortStatus string IsWorktree bool } type Branch struct { Name string DisplayName string Recency string Pushables string // deprecated: use AheadForPull Pullables string // deprecated: use BehindForPull AheadForPull string BehindForPull string AheadForPush string BehindForPush string UpstreamGone bool Head bool DetachedHead bool UpstreamRemote string UpstreamBranch string Subject string CommitHash string } type RemoteBranch struct { Name string RemoteName string } type Remote struct { Name string Urls []string Branches []*RemoteBranch } type Tag struct { Name string Message string } type StashEntry struct { Index int Recency string Name string } type CommitFile struct { Name string ChangeStatus string } type Worktree struct { IsMain bool IsCurrent bool Path string IsPathMissing bool GitDir string Branch string Name string } lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/resolver.go000066400000000000000000000054551500612110400245140ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" ) // takes a prompt that is defined in terms of template strings and resolves the templates to contain actual values type Resolver struct { c *common.Common } func NewResolver(c *common.Common) *Resolver { return &Resolver{c: c} } func (self *Resolver) resolvePrompt( prompt *config.CustomCommandPrompt, resolveTemplate func(string) (string, error), ) (*config.CustomCommandPrompt, error) { var err error result := &config.CustomCommandPrompt{ ValueFormat: prompt.ValueFormat, LabelFormat: prompt.LabelFormat, } result.Title, err = resolveTemplate(prompt.Title) if err != nil { return nil, err } result.InitialValue, err = resolveTemplate(prompt.InitialValue) if err != nil { return nil, err } result.Suggestions.Preset, err = resolveTemplate(prompt.Suggestions.Preset) if err != nil { return nil, err } result.Suggestions.Command, err = resolveTemplate(prompt.Suggestions.Command) if err != nil { return nil, err } result.Body, err = resolveTemplate(prompt.Body) if err != nil { return nil, err } result.Command, err = resolveTemplate(prompt.Command) if err != nil { return nil, err } result.Filter, err = resolveTemplate(prompt.Filter) if err != nil { return nil, err } if prompt.Type == "menu" { result.Options, err = self.resolveMenuOptions(prompt, resolveTemplate) if err != nil { return nil, err } } return result, nil } func (self *Resolver) resolveMenuOptions(prompt *config.CustomCommandPrompt, resolveTemplate func(string) (string, error)) ([]config.CustomCommandMenuOption, error) { newOptions := make([]config.CustomCommandMenuOption, 0, len(prompt.Options)) for _, option := range prompt.Options { newOption, err := self.resolveMenuOption(&option, resolveTemplate) if err != nil { return nil, err } newOptions = append(newOptions, *newOption) } return newOptions, nil } func (self *Resolver) resolveMenuOption(option *config.CustomCommandMenuOption, resolveTemplate func(string) (string, error)) (*config.CustomCommandMenuOption, error) { nameTemplate := option.Name if nameTemplate == "" { // this allows you to only pass values rather than bother with names/descriptions nameTemplate = option.Value } name, err := resolveTemplate(nameTemplate) if err != nil { return nil, err } description, err := resolveTemplate(option.Description) if err != nil { return nil, err } value, err := resolveTemplate(option.Value) if err != nil { return nil, err } return &config.CustomCommandMenuOption{ Name: name, Description: description, Value: value, }, nil } type CustomCommandObject struct { // deprecated. Use Responses instead PromptResponses []string Form map[string]string } lazygit-0.50.0+ds1/pkg/gui/services/custom_commands/session_state_loader.go000066400000000000000000000166321500612110400270630ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/samber/lo" ) // loads the session state at the time that a custom command is invoked, for use // in the custom command's template strings type SessionStateLoader struct { c *helpers.HelperCommon refsHelper *helpers.RefsHelper } func NewSessionStateLoader(c *helpers.HelperCommon, refsHelper *helpers.RefsHelper) *SessionStateLoader { return &SessionStateLoader{ c: c, refsHelper: refsHelper, } } func commitShimFromModelCommit(commit *models.Commit) *Commit { if commit == nil { return nil } return &Commit{ Hash: commit.Hash(), Sha: commit.Hash(), Name: commit.Name, Status: commit.Status, Action: commit.Action, Tags: commit.Tags, ExtraInfo: commit.ExtraInfo, AuthorName: commit.AuthorName, AuthorEmail: commit.AuthorEmail, UnixTimestamp: commit.UnixTimestamp, Divergence: commit.Divergence, Parents: commit.Parents(), } } func fileShimFromModelFile(file *models.File) *File { if file == nil { return nil } return &File{ Name: file.Path, PreviousName: file.PreviousPath, HasStagedChanges: file.HasStagedChanges, HasUnstagedChanges: file.HasUnstagedChanges, Tracked: file.Tracked, Added: file.Added, Deleted: file.Deleted, HasMergeConflicts: file.HasMergeConflicts, HasInlineMergeConflicts: file.HasInlineMergeConflicts, DisplayString: file.DisplayString, ShortStatus: file.ShortStatus, IsWorktree: file.IsWorktree, } } func branchShimFromModelBranch(branch *models.Branch) *Branch { if branch == nil { return nil } return &Branch{ Name: branch.Name, DisplayName: branch.DisplayName, Recency: branch.Recency, Pushables: branch.AheadForPull, Pullables: branch.BehindForPull, AheadForPull: branch.AheadForPull, BehindForPull: branch.BehindForPull, AheadForPush: branch.AheadForPush, BehindForPush: branch.BehindForPush, UpstreamGone: branch.UpstreamGone, Head: branch.Head, DetachedHead: branch.DetachedHead, UpstreamRemote: branch.UpstreamRemote, UpstreamBranch: branch.UpstreamBranch, Subject: branch.Subject, CommitHash: branch.CommitHash, } } func remoteBranchShimFromModelRemoteBranch(remoteBranch *models.RemoteBranch) *RemoteBranch { if remoteBranch == nil { return nil } return &RemoteBranch{ Name: remoteBranch.Name, RemoteName: remoteBranch.RemoteName, } } func remoteShimFromModelRemote(remote *models.Remote) *Remote { if remote == nil { return nil } return &Remote{ Name: remote.Name, Urls: remote.Urls, Branches: lo.Map(remote.Branches, func(branch *models.RemoteBranch, _ int) *RemoteBranch { return remoteBranchShimFromModelRemoteBranch(branch) }), } } func tagShimFromModelRemote(tag *models.Tag) *Tag { if tag == nil { return nil } return &Tag{ Name: tag.Name, Message: tag.Message, } } func stashEntryShimFromModelRemote(stashEntry *models.StashEntry) *StashEntry { if stashEntry == nil { return nil } return &StashEntry{ Index: stashEntry.Index, Recency: stashEntry.Recency, Name: stashEntry.Name, } } func commitFileShimFromModelRemote(commitFile *models.CommitFile) *CommitFile { if commitFile == nil { return nil } return &CommitFile{ Name: commitFile.Path, ChangeStatus: commitFile.ChangeStatus, } } func worktreeShimFromModelRemote(worktree *models.Worktree) *Worktree { if worktree == nil { return nil } return &Worktree{ IsMain: worktree.IsMain, IsCurrent: worktree.IsCurrent, Path: worktree.Path, IsPathMissing: worktree.IsPathMissing, GitDir: worktree.GitDir, Branch: worktree.Branch, Name: worktree.Name, } } type CommitRange struct { From string To string } func makeCommitRange(commits []*models.Commit, _ int, _ int) *CommitRange { if len(commits) == 0 { return nil } return &CommitRange{ From: commits[len(commits)-1].Hash(), To: commits[0].Hash(), } } // SessionState captures the current state of the application for use in custom commands type SessionState struct { SelectedLocalCommit *Commit // deprecated, use SelectedCommit SelectedReflogCommit *Commit // deprecated, use SelectedCommit SelectedSubCommit *Commit // deprecated, use SelectedCommit SelectedCommit *Commit SelectedCommitRange *CommitRange SelectedFile *File SelectedPath string SelectedLocalBranch *Branch SelectedRemoteBranch *RemoteBranch SelectedRemote *Remote SelectedTag *Tag SelectedStashEntry *StashEntry SelectedCommitFile *CommitFile SelectedCommitFilePath string SelectedWorktree *Worktree CheckedOutBranch *Branch } func (self *SessionStateLoader) call() *SessionState { selectedLocalCommit := commitShimFromModelCommit(self.c.Contexts().LocalCommits.GetSelected()) selectedLocalCommitRange := makeCommitRange(self.c.Contexts().LocalCommits.GetSelectedItems()) selectedReflogCommit := commitShimFromModelCommit(self.c.Contexts().ReflogCommits.GetSelected()) selectedReflogCommitRange := makeCommitRange(self.c.Contexts().ReflogCommits.GetSelectedItems()) selectedSubCommit := commitShimFromModelCommit(self.c.Contexts().SubCommits.GetSelected()) selectedSubCommitRange := makeCommitRange(self.c.Contexts().SubCommits.GetSelectedItems()) selectedCommit := selectedLocalCommit selectedCommitRange := selectedLocalCommitRange if self.c.Context().IsCurrentOrParent(self.c.Contexts().ReflogCommits) { selectedCommit = selectedReflogCommit selectedCommitRange = selectedReflogCommitRange } else if self.c.Context().IsCurrentOrParent(self.c.Contexts().SubCommits) { selectedCommit = selectedSubCommit selectedCommitRange = selectedSubCommitRange } selectedPath := self.c.Contexts().Files.GetSelectedPath() selectedCommitFilePath := self.c.Contexts().CommitFiles.GetSelectedPath() if self.c.Context().IsCurrent(self.c.Contexts().CommitFiles) { selectedPath = selectedCommitFilePath } return &SessionState{ SelectedFile: fileShimFromModelFile(self.c.Contexts().Files.GetSelectedFile()), SelectedPath: selectedPath, SelectedLocalCommit: selectedLocalCommit, SelectedReflogCommit: selectedReflogCommit, SelectedSubCommit: selectedSubCommit, SelectedCommit: selectedCommit, SelectedCommitRange: selectedCommitRange, SelectedLocalBranch: branchShimFromModelBranch(self.c.Contexts().Branches.GetSelected()), SelectedRemoteBranch: remoteBranchShimFromModelRemoteBranch(self.c.Contexts().RemoteBranches.GetSelected()), SelectedRemote: remoteShimFromModelRemote(self.c.Contexts().Remotes.GetSelected()), SelectedTag: tagShimFromModelRemote(self.c.Contexts().Tags.GetSelected()), SelectedStashEntry: stashEntryShimFromModelRemote(self.c.Contexts().Stash.GetSelected()), SelectedCommitFile: commitFileShimFromModelRemote(self.c.Contexts().CommitFiles.GetSelectedFile()), SelectedCommitFilePath: selectedCommitFilePath, SelectedWorktree: worktreeShimFromModelRemote(self.c.Contexts().Worktrees.GetSelected()), CheckedOutBranch: branchShimFromModelBranch(self.refsHelper.GetCheckedOutRef()), } } lazygit-0.50.0+ds1/pkg/gui/status/000077500000000000000000000000001500612110400166205ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/status/status_manager.go000066400000000000000000000054611500612110400221720ustar00rootroot00000000000000package status import ( "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" ) // StatusManager's job is to handle queuing of loading states and toast notifications // that you see at the bottom left of the screen. type StatusManager struct { statuses []appStatus nextId int mutex deadlock.Mutex } // Can be used to manipulate a waiting status while it is running (e.g. pause // and resume it) type WaitingStatusHandle struct { statusManager *StatusManager message string renderFunc func() id int } func (self *WaitingStatusHandle) Show() { self.id = self.statusManager.addStatus(self.message, "waiting", types.ToastKindStatus) self.renderFunc() } func (self *WaitingStatusHandle) Hide() { self.statusManager.removeStatus(self.id) } type appStatus struct { message string statusType string color gocui.Attribute id int } func NewStatusManager() *StatusManager { return &StatusManager{} } func (self *StatusManager) WithWaitingStatus(message string, renderFunc func(), f func(*WaitingStatusHandle) error) error { handle := &WaitingStatusHandle{statusManager: self, message: message, renderFunc: renderFunc, id: -1} handle.Show() defer handle.Hide() return f(handle) } func (self *StatusManager) AddToastStatus(message string, kind types.ToastKind) int { id := self.addStatus(message, "toast", kind) go func() { delay := lo.Ternary(kind == types.ToastKindError, time.Second*4, time.Second*2) time.Sleep(delay) self.removeStatus(id) }() return id } func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (string, gocui.Attribute) { if len(self.statuses) == 0 { return "", gocui.ColorDefault } topStatus := self.statuses[0] if topStatus.statusType == "waiting" { return topStatus.message + " " + utils.Loader(time.Now(), userConfig.Gui.Spinner), topStatus.color } return topStatus.message, topStatus.color } func (self *StatusManager) HasStatus() bool { return len(self.statuses) > 0 } func (self *StatusManager) addStatus(message string, statusType string, kind types.ToastKind) int { self.mutex.Lock() defer self.mutex.Unlock() self.nextId++ id := self.nextId color := gocui.ColorCyan if kind == types.ToastKindError { color = gocui.ColorRed } newStatus := appStatus{ message: message, statusType: statusType, color: color, id: id, } self.statuses = append([]appStatus{newStatus}, self.statuses...) return id } func (self *StatusManager) removeStatus(id int) { self.mutex.Lock() defer self.mutex.Unlock() self.statuses = lo.Filter(self.statuses, func(status appStatus, _ int) bool { return status.id != id }) } lazygit-0.50.0+ds1/pkg/gui/style/000077500000000000000000000000001500612110400164355ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/style/basic_styles.go000066400000000000000000000035511500612110400214540ustar00rootroot00000000000000package style import ( "text/template" "github.com/gookit/color" ) var ( FgWhite = FromBasicFg(color.FgWhite) FgLightWhite = FromBasicFg(color.FgLightWhite) FgBlack = FromBasicFg(color.FgBlack) FgBlackLighter = FromBasicFg(color.FgBlack.Light()) FgCyan = FromBasicFg(color.FgCyan) FgRed = FromBasicFg(color.FgRed) FgGreen = FromBasicFg(color.FgGreen) FgBlue = FromBasicFg(color.FgBlue) FgYellow = FromBasicFg(color.FgYellow) FgMagenta = FromBasicFg(color.FgMagenta) FgDefault = FromBasicFg(color.FgDefault) BgWhite = FromBasicBg(color.BgWhite) BgBlack = FromBasicBg(color.BgBlack) BgRed = FromBasicBg(color.BgRed) BgGreen = FromBasicBg(color.BgGreen) BgYellow = FromBasicBg(color.BgYellow) BgBlue = FromBasicBg(color.BgBlue) BgMagenta = FromBasicBg(color.BgMagenta) BgCyan = FromBasicBg(color.BgCyan) BgDefault = FromBasicBg(color.BgDefault) // will not print any colour escape codes, including the reset escape code Nothing = New() AttrUnderline = New().SetUnderline() AttrBold = New().SetBold() ColorMap = map[string]struct { Foreground TextStyle Background TextStyle }{ "default": {FgDefault, BgDefault}, "black": {FgBlack, BgBlack}, "red": {FgRed, BgRed}, "green": {FgGreen, BgGreen}, "yellow": {FgYellow, BgYellow}, "blue": {FgBlue, BgBlue}, "magenta": {FgMagenta, BgMagenta}, "cyan": {FgCyan, BgCyan}, "white": {FgWhite, BgWhite}, } ) func FromBasicFg(fg color.Color) TextStyle { return New().SetFg(NewBasicColor(fg)) } func FromBasicBg(bg color.Color) TextStyle { return New().SetBg(NewBasicColor(bg)) } func TemplateFuncMapAddColors(m template.FuncMap) template.FuncMap { for k, v := range ColorMap { m[k] = v.Foreground.Sprint } m["underline"] = color.OpUnderscore.Sprint m["bold"] = color.OpBold.Sprint return m } lazygit-0.50.0+ds1/pkg/gui/style/color.go000066400000000000000000000011661500612110400201060ustar00rootroot00000000000000package style import "github.com/gookit/color" type Color struct { rgb *color.RGBColor basic *color.Color } func NewRGBColor(cl color.RGBColor) Color { c := Color{} c.rgb = &cl return c } func NewBasicColor(cl color.Color) Color { c := Color{} c.basic = &cl return c } func (c Color) IsRGB() bool { return c.rgb != nil } func (c Color) ToRGB(isBg bool) Color { if c.IsRGB() { return c } if isBg { // We need to convert bg color to fg color // This is a gookit/color bug, // https://github.com/gookit/color/issues/39 return NewRGBColor((*c.basic - 10).RGB()) } return NewRGBColor(c.basic.RGB()) } lazygit-0.50.0+ds1/pkg/gui/style/decoration.go000066400000000000000000000017331500612110400211170ustar00rootroot00000000000000package style import "github.com/gookit/color" type Decoration struct { bold bool underline bool reverse bool strikethrough bool } func (d *Decoration) SetBold() { d.bold = true } func (d *Decoration) SetUnderline() { d.underline = true } func (d *Decoration) SetReverse() { d.reverse = true } func (d *Decoration) SetStrikethrough() { d.strikethrough = true } func (d Decoration) ToOpts() color.Opts { opts := make([]color.Color, 0, 3) if d.bold { opts = append(opts, color.OpBold) } if d.underline { opts = append(opts, color.OpUnderscore) } if d.reverse { opts = append(opts, color.OpReverse) } if d.strikethrough { opts = append(opts, color.OpStrikethrough) } return opts } func (d Decoration) Merge(other Decoration) Decoration { if other.bold { d.bold = true } if other.underline { d.underline = true } if other.reverse { d.reverse = true } if other.strikethrough { d.strikethrough = true } return d } lazygit-0.50.0+ds1/pkg/gui/style/hyperlink.go000066400000000000000000000005661500612110400210000ustar00rootroot00000000000000package style import "fmt" // Render the given text as an OSC 8 hyperlink func PrintHyperlink(text string, link string) string { return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", link, text) } // Render a link where the text is the same as a link func PrintSimpleHyperlink(link string) string { return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", link, link) } lazygit-0.50.0+ds1/pkg/gui/style/style_test.go000066400000000000000000000122311500612110400211620ustar00rootroot00000000000000package style import ( "bytes" "testing" "text/template" "github.com/gookit/color" "github.com/stretchr/testify/assert" "github.com/xo/terminfo" ) func TestMerge(t *testing.T) { type scenario struct { name string toMerge []TextStyle expectedStyle TextStyle expectedStr string } fgRed := color.FgRed bgRed := color.BgRed fgBlue := color.FgBlue rgbPinkLib := color.Rgb(0xFF, 0x00, 0xFF) rgbPink := NewRGBColor(rgbPinkLib) rgbYellowLib := color.Rgb(0xFF, 0xFF, 0x00) rgbYellow := NewRGBColor(rgbYellowLib) strToPrint := "foo" scenarios := []scenario{ { "no color", nil, TextStyle{Style: color.Style{}}, "foo", }, { "only fg color", []TextStyle{FgRed}, TextStyle{fg: &Color{basic: &fgRed}, Style: color.Style{fgRed}}, "\x1b[31mfoo\x1b[0m", }, { "only bg color", []TextStyle{BgRed}, TextStyle{bg: &Color{basic: &bgRed}, Style: color.Style{bgRed}}, "\x1b[41mfoo\x1b[0m", }, { "fg and bg color", []TextStyle{FgBlue, BgRed}, TextStyle{ fg: &Color{basic: &fgBlue}, bg: &Color{basic: &bgRed}, Style: color.Style{fgBlue, bgRed}, }, "\x1b[34;41mfoo\x1b[0m", }, { "single attribute", []TextStyle{AttrBold}, TextStyle{ decoration: Decoration{bold: true}, Style: color.Style{color.OpBold}, }, "\x1b[1mfoo\x1b[0m", }, { "multiple attributes", []TextStyle{AttrBold, AttrUnderline}, TextStyle{ decoration: Decoration{ bold: true, underline: true, }, Style: color.Style{color.OpBold, color.OpUnderscore}, }, "\x1b[1;4mfoo\x1b[0m", }, { "multiple attributes and colors", []TextStyle{AttrBold, FgBlue, AttrUnderline, BgRed}, TextStyle{ fg: &Color{basic: &fgBlue}, bg: &Color{basic: &bgRed}, decoration: Decoration{ bold: true, underline: true, }, Style: color.Style{fgBlue, bgRed, color.OpBold, color.OpUnderscore}, }, "\x1b[34;41;1;4mfoo\x1b[0m", }, { "rgb fg color", []TextStyle{New().SetFg(rgbPink)}, TextStyle{ fg: &rgbPink, Style: color.NewRGBStyle(rgbPinkLib).SetOpts(color.Opts{}), }, // '38;2' qualifies an RGB foreground color "\x1b[38;2;255;0;255mfoo\x1b[0m", }, { "rgb fg and bg color", []TextStyle{New().SetFg(rgbPink).SetBg(rgbYellow)}, TextStyle{ fg: &rgbPink, bg: &rgbYellow, Style: color.NewRGBStyle(rgbPinkLib, rgbYellowLib).SetOpts(color.Opts{}), }, // '48;2' qualifies an RGB background color "\x1b[38;2;255;0;255;48;2;255;255;0mfoo\x1b[0m", }, { "rgb fg and bg color with opts", []TextStyle{AttrBold, New().SetFg(rgbPink).SetBg(rgbYellow), AttrUnderline}, TextStyle{ fg: &rgbPink, bg: &rgbYellow, decoration: Decoration{ bold: true, underline: true, }, Style: color.NewRGBStyle(rgbPinkLib, rgbYellowLib).SetOpts(color.Opts{color.OpBold, color.OpUnderscore}), }, "\x1b[38;2;255;0;255;48;2;255;255;0;1;4mfoo\x1b[0m", }, { "mix color-16 (background) with rgb (foreground)", []TextStyle{New().SetFg(rgbYellow), BgRed}, TextStyle{ fg: &rgbYellow, bg: &Color{basic: &bgRed}, Style: color.NewRGBStyle( rgbYellowLib, fgRed.RGB(), // We need to use FG here, https://github.com/gookit/color/issues/39 ).SetOpts(color.Opts{}), }, "\x1b[38;2;255;255;0;48;2;197;30;20mfoo\x1b[0m", }, { "mix color-16 (foreground) with rgb (background)", []TextStyle{FgRed, New().SetBg(rgbYellow)}, TextStyle{ fg: &Color{basic: &fgRed}, bg: &rgbYellow, Style: color.NewRGBStyle( fgRed.RGB(), rgbYellowLib, ).SetOpts(color.Opts{}), }, "\x1b[38;2;197;30;20;48;2;255;255;0mfoo\x1b[0m", }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) defer color.ForceSetColorLevel(oldColorLevel) for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { style := New() for _, other := range s.toMerge { style = style.MergeStyle(other) } assert.Equal(t, s.expectedStyle, style) assert.Equal(t, s.expectedStr, style.Sprint(strToPrint)) }) } } func TestTemplateFuncMapAddColors(t *testing.T) { type scenario struct { name string tmpl string expect string } scenarios := []scenario{ { "normal template", "{{ .Foo }}", "bar", }, { "colored string", "{{ .Foo | red }}", "\x1b[31mbar\x1b[0m", }, { "string with decorator", "{{ .Foo | bold }}", "\x1b[1mbar\x1b[0m", }, { "string with color and decorator", "{{ .Foo | bold | red }}", "\x1b[31m\x1b[1mbar\x1b[0m\x1b[0m", }, { "multiple string with different colors", "{{ .Foo | red }} - {{ .Foo | blue }}", "\x1b[31mbar\x1b[0m - \x1b[34mbar\x1b[0m", }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) defer color.ForceSetColorLevel(oldColorLevel) for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { tmpl, err := template.New("test template").Funcs(TemplateFuncMapAddColors(template.FuncMap{})).Parse(s.tmpl) assert.NoError(t, err) buff := bytes.NewBuffer(nil) err = tmpl.Execute(buff, struct{ Foo string }{"bar"}) assert.NoError(t, err) assert.Equal(t, s.expect, buff.String()) }) } } lazygit-0.50.0+ds1/pkg/gui/style/text_style.go000066400000000000000000000073001500612110400211700ustar00rootroot00000000000000package style import ( "github.com/gookit/color" ) // A TextStyle contains a foreground color, background color, and // decorations (bold/underline/reverse). // // Colors may each be either 16-bit or 24-bit RGB colors. When // we need to produce a string with a TextStyle, if either foreground or // background color is RGB, we'll promote the other color component to RGB as well. // We could simplify this code by forcing everything to be RGB, but we're not // sure how compatible or efficient that would be with various terminals. // Lazygit will typically stick to 16-bit colors, but users may configure RGB colors. // // TextStyles are value objects, not entities, so for example if you want to // add the bold decoration to a TextStyle, we'll create a new TextStyle with // that decoration applied. // // Decorations are additive, so when we merge two TextStyles, if either is bold // then the resulting style will also be bold. // // So that we aren't rederiving the underlying style each time we want to print // a string, we derive it when a new TextStyle is created and store it in the // `style` field. type TextStyle struct { fg *Color bg *Color decoration Decoration // making this public so that we can use a type switch to get to the underlying // value so we can cache styles. This is very much a hack. Style Sprinter } type Sprinter interface { Sprint(a ...interface{}) string Sprintf(format string, a ...interface{}) string } func New() TextStyle { s := TextStyle{} s.Style = s.deriveStyle() return s } func (b TextStyle) Sprint(a ...interface{}) string { return b.Style.Sprint(a...) } func (b TextStyle) Sprintf(format string, a ...interface{}) string { return b.Style.Sprintf(format, a...) } // note that our receiver here is not a pointer which means we're receiving a // copy of the original TextStyle. This allows us to mutate and return that // TextStyle receiver without actually modifying the original. func (b TextStyle) SetBold() TextStyle { b.decoration.SetBold() b.Style = b.deriveStyle() return b } func (b TextStyle) SetUnderline() TextStyle { b.decoration.SetUnderline() b.Style = b.deriveStyle() return b } func (b TextStyle) SetReverse() TextStyle { b.decoration.SetReverse() b.Style = b.deriveStyle() return b } func (b TextStyle) SetStrikethrough() TextStyle { b.decoration.SetStrikethrough() b.Style = b.deriveStyle() return b } func (b TextStyle) SetBg(color Color) TextStyle { b.bg = &color b.Style = b.deriveStyle() return b } func (b TextStyle) SetFg(color Color) TextStyle { b.fg = &color b.Style = b.deriveStyle() return b } func (b TextStyle) MergeStyle(other TextStyle) TextStyle { b.decoration = b.decoration.Merge(other.decoration) if other.fg != nil { b.fg = other.fg } if other.bg != nil { b.bg = other.bg } b.Style = b.deriveStyle() return b } func (b TextStyle) deriveStyle() Sprinter { if b.fg == nil && b.bg == nil { return color.Style(b.decoration.ToOpts()) } isRgb := (b.fg != nil && b.fg.IsRGB()) || (b.bg != nil && b.bg.IsRGB()) if isRgb { return b.deriveRGBStyle() } return b.deriveBasicStyle() } func (b TextStyle) deriveBasicStyle() color.Style { style := make([]color.Color, 0, 5) if b.fg != nil { style = append(style, *b.fg.basic) } if b.bg != nil { style = append(style, *b.bg.basic) } style = append(style, b.decoration.ToOpts()...) return color.Style(style) } func (b TextStyle) deriveRGBStyle() *color.RGBStyle { style := &color.RGBStyle{} if b.fg != nil { style.SetFg(*b.fg.ToRGB(false).rgb) } if b.bg != nil { // We need to convert the bg firstly to a foreground color, // For more info see style.SetBg(*b.bg.ToRGB(true).rgb) } style.SetOpts(b.decoration.ToOpts()) return style } lazygit-0.50.0+ds1/pkg/gui/tasks_adapter.go000066400000000000000000000061361500612110400204570ustar00rootroot00000000000000package gui import ( "io" "os/exec" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/tasks" ) func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { cmdStr := strings.Join(cmd.Args, " ") gui.c.Log.WithField( "command", cmdStr, ).Debug("RunCommand") manager := gui.getManager(view) start := func() (*exec.Cmd, io.Reader) { r, err := cmd.StdoutPipe() if err != nil { gui.c.Log.Error(err) } cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { gui.c.Log.Error(err) } return cmd, r } linesToRead := gui.linesToReadFromCmdTask(view) if err := manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, nil), cmdStr); err != nil { gui.c.Log.Error(err) } return nil } func (gui *Gui) newStringTask(view *gocui.View, str string) error { // using str so that if rendering the exact same thing we don't reset the origin return gui.newStringTaskWithKey(view, str, str) } func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error { manager := gui.getManager(view) f := func(tasks.TaskOpts) error { gui.c.SetViewContent(view, str) return nil } if err := manager.NewTask(f, manager.GetTaskKey()); err != nil { return err } return nil } func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX int, originY int) error { manager := gui.getManager(view) f := func(tasks.TaskOpts) error { gui.c.SetViewContent(view, str) view.SetOrigin(originX, originY) return nil } if err := manager.NewTask(f, manager.GetTaskKey()); err != nil { return err } return nil } func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) error { manager := gui.getManager(view) f := func(tasks.TaskOpts) error { gui.c.ResetViewOrigin(view) gui.c.SetViewContent(view, str) return nil } if err := manager.NewTask(f, key); err != nil { return err } return nil } func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { manager, ok := gui.viewBufferManagerMap[view.Name()] if !ok { manager = tasks.NewViewBufferManager( gui.Log, view, func() { // we could clear here, but that actually has the effect of causing a flicker // where the view may contain no content momentarily as the gui refreshes. // Instead, we're rewinding the write pointer so that we will just start // overwriting the existing content from the top down. Once we've reached // the end of the content do display, we call view.FlushStaleCells() to // clear out the remaining content from the previous render. view.Reset() }, func() { gui.render() }, func() { // Need to check if the content of the view is well past the origin. linesHeight := view.ViewLinesHeight() _, originY := view.Origin() if linesHeight < originY { newOriginY := linesHeight view.SetOrigin(0, newOriginY) } view.FlushStaleCells() }, func() { view.SetOrigin(0, 0) }, func() gocui.Task { return gui.c.GocuiGui().NewTask() }, ) gui.viewBufferManagerMap[view.Name()] = manager } return manager } lazygit-0.50.0+ds1/pkg/gui/test_mode.go000066400000000000000000000025551500612110400176160ustar00rootroot00000000000000package gui import ( "log" "os" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/popup" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/utils" ) type IntegrationTest interface { Run(*GuiDriver) } func (gui *Gui) handleTestMode() { test := gui.integrationTest if os.Getenv(components.SANDBOX_ENV_VAR) == "true" { return } if test != nil { isIdleChan := make(chan struct{}) gui.c.GocuiGui().AddIdleListener(isIdleChan) waitUntilIdle := func() { <-isIdleChan } go func() { waitUntilIdle() toastChan := make(chan string, 100) gui.PopupHandler.(*popup.PopupHandler).SetToastFunc( func(message string, kind types.ToastKind) { toastChan <- message }) test.Run(&GuiDriver{gui: gui, isIdleChan: isIdleChan, toastChan: toastChan, headless: Headless()}) gui.g.Update(func(*gocui.Gui) error { return gocui.ErrQuit }) waitUntilIdle() time.Sleep(time.Second * 1) log.Fatal("gocui should have already exited") }() if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" { go utils.Safe(func() { time.Sleep(time.Second * 40) log.Fatal("40 seconds is up, lazygit recording took too long to complete") }) } } } func Headless() bool { return os.Getenv("HEADLESS") != "" } lazygit-0.50.0+ds1/pkg/gui/types/000077500000000000000000000000001500612110400164415ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/gui/types/common.go000066400000000000000000000303731500612110400202660ustar00rootroot00000000000000package types import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) type HelperCommon struct { *ContextCommon } type ContextCommon struct { *common.Common IGuiCommon } type IGuiCommon interface { IPopupHandler LogAction(action string) LogCommand(cmdStr string, isCommandLine bool) // we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate Refresh(RefreshOptions) error // we call this when we've changed something in the view model but not the actual model, // e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this // case would be overkill, although refresh will internally call 'PostRefreshUpdate' PostRefreshUpdate(Context) // renders string to a view without resetting its origin SetViewContent(view *gocui.View, content string) // resets cursor and origin of view. Often used before calling SetViewContent ResetViewOrigin(view *gocui.View) // this just re-renders the screen Render() // allows rendering to main views (i.e. the ones to the right of the side panel) // in such a way that avoids concurrency issues when there are slow commands // to display the output of RenderToMainViews(opts RefreshMainOpts) // used purely for the sake of RenderToMainViews to provide the pair of main views we want to render to MainViewPairs() MainViewPairs // return the view buffer manager for the given view, or nil if it doesn't have one GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager // returns true if command completed successfully RunSubprocess(cmdObj oscommands.ICmdObj) (bool, error) RunSubprocessAndRefresh(oscommands.ICmdObj) error Context() IContextMgr ContextForKey(key ContextKey) Context GetConfig() config.AppConfigurer GetAppState() *config.AppState SaveAppState() error SaveAppStateAndLogError() // Runs the given function on the UI thread (this is for things like showing a popup asking a user for input). // Only necessary to call if you're not already on the UI thread i.e. you're inside a goroutine. // All controller handlers are executed on the UI thread. OnUIThread(f func() error) // Runs a function in a goroutine. Use this whenever you want to run a goroutine and keep track of the fact // that lazygit is still busy. See docs/dev/Busy.md OnWorker(f func(gocui.Task) error) // Function to call at the end of our 'layout' function which renders views // For example, you may want a view's line to be focused only after that view is // resized, if in accordion mode. AfterLayout(f func() error) // Wraps a function, attaching the given operation to the given item while // the function is executing, and also causes the given context to be // redrawn periodically. This allows the operation to be visualized with a // spinning loader animation (e.g. when a branch is being pushed). WithInlineStatus(item HasUrn, operation ItemOperation, contextKey ContextKey, f func(gocui.Task) error) error // returns the gocui Gui struct. There is a good chance you don't actually want to use // this struct and instead want to use another method above GocuiGui() *gocui.Gui Views() Views Git() *commands.GitCommand OS() *oscommands.OSCommand Model() *Model Modes() *Modes Mutexes() Mutexes State() IStateAccessor KeybindingsOpts() KeybindingsOpts CallKeybindingHandler(binding *Binding) error ResetKeybindings() error // hopefully we can remove this once we've moved all our keybinding stuff out of the gui god struct. GetInitialKeybindingsWithCustomCommands() ([]*Binding, []*gocui.ViewMouseBinding) // Returns true if we're running an integration test RunningIntegrationTest() bool // Returns true if we're in a demo recording/playback InDemo() bool } type IModeMgr interface { IsAnyModeActive() bool } type IPopupHandler interface { // The global error handler for gocui. Not to be used by application code. ErrorHandler(err error) error // Shows a notification popup with the given title and message to the user. // // This is a convenience wrapper around Confirm(), thus the popup can be closed using both 'Enter' and 'ESC'. Alert(title string, message string) // Shows a popup asking the user for confirmation. Confirm(opts ConfirmOpts) // Shows a popup prompting the user for input. Prompt(opts PromptOpts) WithWaitingStatus(message string, f func(gocui.Task) error) error WithWaitingStatusSync(message string, f func() error) error Menu(opts CreateMenuOptions) error Toast(message string) ErrorToast(message string) SetToastFunc(func(string, ToastKind)) GetPromptInput() string } type ToastKind int const ( ToastKindStatus ToastKind = iota ToastKindError ) type CreateMenuOptions struct { Title string Prompt string // a message that will be displayed above the menu options Items []*MenuItem HideCancel bool ColumnAlignment []utils.Alignment } type CreatePopupPanelOpts struct { HasLoader bool Editable bool Title string Prompt string HandleConfirm func() error HandleConfirmPrompt func(string) error HandleClose func() error HandleDeleteSuggestion func(int) error FindSuggestionsFunc func(string) []*Suggestion Mask bool AllowEditSuggestion bool } type ConfirmOpts struct { Title string Prompt string HandleConfirm func() error HandleClose func() error FindSuggestionsFunc func(string) []*Suggestion Editable bool Mask bool } type PromptOpts struct { Title string InitialContent string FindSuggestionsFunc func(string) []*Suggestion HandleConfirm func(string) error AllowEditSuggestion bool // CAPTURE THIS HandleClose func() error HandleDeleteSuggestion func(int) error Mask bool } type MenuSection struct { Title string Column int // The column that this section title should be aligned with } type DisabledReason struct { Text string // When trying to invoke a disabled key binding or menu item, we normally // show the disabled reason as a toast; setting this to true shows it as an // error panel instead. This is useful if the text is very long, or if it is // important enough to show it more prominently, or both. ShowErrorInPanel bool } type MenuWidget int const ( MenuWidgetNone MenuWidget = iota MenuWidgetRadioButtonSelected MenuWidgetRadioButtonUnselected MenuWidgetCheckboxSelected MenuWidgetCheckboxUnselected ) func MakeMenuRadioButton(value bool) MenuWidget { if value { return MenuWidgetRadioButtonSelected } return MenuWidgetRadioButtonUnselected } func MakeMenuCheckBox(value bool) MenuWidget { if value { return MenuWidgetCheckboxSelected } return MenuWidgetCheckboxUnselected } type MenuItem struct { Label string // alternative to Label. Allows specifying columns which will be auto-aligned LabelColumns []string OnPress func() error // Only applies when Label is used OpensMenu bool // If Key is defined it allows the user to press the key to invoke the menu // item, as opposed to having to navigate to it Key Key // A widget to show in front of the menu item. Supported widget types are // checkboxes and radio buttons, // This only handles the rendering of the widget; the behavior needs to be // provided by the client. Widget MenuWidget // The tooltip will be displayed upon highlighting the menu item Tooltip string // If non-nil, show this in a tooltip, style the menu item as disabled, // and refuse to invoke the command DisabledReason *DisabledReason // Can be used to group menu items into sections with headers. MenuItems // with the same Section should be contiguous, and will automatically get a // section header. If nil, the item is not part of a section. // Note that pointer comparison is used to determine whether two menu items // belong to the same section, so make sure all your items in a given // section point to the same MenuSection instance. Section *MenuSection } // Defining this for the sake of conforming to the HasID interface, which is used // in list contexts. func (self *MenuItem) ID() string { return self.Label } type Model struct { CommitFiles []*models.CommitFile Files []*models.File Submodules []*models.SubmoduleConfig Branches []*models.Branch Commits []*models.Commit StashEntries []*models.StashEntry SubCommits []*models.Commit Remotes []*models.Remote Worktrees []*models.Worktree // FilteredReflogCommits are the ones that appear in the reflog panel. // when in filtering mode we only include the ones that match the given path FilteredReflogCommits []*models.Commit // ReflogCommits are the ones used by the branches panel to obtain recency values // if we're not in filtering mode, CommitFiles and FilteredReflogCommits will be // one and the same ReflogCommits []*models.Commit BisectInfo *git_commands.BisectInfo WorkingTreeStateAtLastCommitRefresh models.WorkingTreeState RemoteBranches []*models.RemoteBranch Tags []*models.Tag // Name of the currently checked out branch. This will be set even when // we're on a detached head because we're rebasing or bisecting. CheckedOutBranch string MainBranches *git_commands.MainBranches // for displaying suggestions while typing in a file name FilesTrie *patricia.Trie Authors map[string]*models.Author HashPool *utils.StringPool } // if you add a new mutex here be sure to instantiate it. We're using pointers to // mutexes so that we can pass the mutexes to controllers. type Mutexes struct { RefreshingFilesMutex *deadlock.Mutex RefreshingBranchesMutex *deadlock.Mutex RefreshingStatusMutex *deadlock.Mutex LocalCommitsMutex *deadlock.Mutex SubCommitsMutex *deadlock.Mutex AuthorsMutex *deadlock.Mutex SubprocessMutex *deadlock.Mutex PopupMutex *deadlock.Mutex PtyMutex *deadlock.Mutex } // A long-running operation associated with an item. For example, we'll show // that a branch is being pushed from so that there's visual feedback about // what's happening and so that you can see multiple branches' concurrent // operations type ItemOperation int const ( ItemOperationNone ItemOperation = iota ItemOperationPushing ItemOperationPulling ItemOperationFastForwarding ItemOperationDeleting ItemOperationFetching ItemOperationCheckingOut ) type HasUrn interface { URN() string } type IStateAccessor interface { GetRepoPathStack() *utils.StringStack GetRepoState() IRepoStateAccessor // tells us whether we're currently updating lazygit GetUpdating() bool SetUpdating(bool) SetIsRefreshingFiles(bool) GetIsRefreshingFiles() bool GetShowExtrasWindow() bool SetShowExtrasWindow(bool) GetRetainOriginalDir() bool SetRetainOriginalDir(bool) GetItemOperation(item HasUrn) ItemOperation SetItemOperation(item HasUrn, operation ItemOperation) ClearItemOperation(item HasUrn) } type IRepoStateAccessor interface { GetViewsSetup() bool GetWindowViewNameMap() *utils.ThreadSafeMap[string, string] GetStartupStage() StartupStage SetStartupStage(stage StartupStage) GetCurrentPopupOpts() *CreatePopupPanelOpts SetCurrentPopupOpts(*CreatePopupPanelOpts) GetScreenMode() ScreenMode SetScreenMode(ScreenMode) InSearchPrompt() bool GetSearchState() *SearchState SetSplitMainPanel(bool) GetSplitMainPanel() bool } // startup stages so we don't need to load everything at once type StartupStage int const ( INITIAL StartupStage = iota COMPLETE ) // screen sizing determines how much space your selected window takes up (window // as in panel, not your terminal's window). Sometimes you want a bit more space // to see the contents of a panel, and this keeps track of how much maximisation // you've set type ScreenMode int const ( SCREEN_NORMAL ScreenMode = iota SCREEN_HALF SCREEN_FULL ) lazygit-0.50.0+ds1/pkg/gui/types/common_commands.go000066400000000000000000000002061500612110400221370ustar00rootroot00000000000000package types type CheckoutRefOptions struct { WaitingStatus string EnvVars []string OnRefNotFound func(ref string) error } lazygit-0.50.0+ds1/pkg/gui/types/context.go000066400000000000000000000216021500612110400204550ustar00rootroot00000000000000package types import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" ) type ContextKind int const ( // this is your files, branches, commits, contexts etc. They're all on the left hand side // and you can cycle through them. SIDE_CONTEXT ContextKind = iota // This is either the left or right 'main' contexts that appear to the right of the side contexts MAIN_CONTEXT // A persistent popup is one that has its own identity e.g. the commit message context. // When you open a popup over it, we'll let you return to it upon pressing escape PERSISTENT_POPUP // A temporary popup is one that could be used for various things (e.g. a generic menu or confirmation popup). // Because we reuse these contexts, they're temporary in that you can't return to them after you've switched from them // to some other context, because the context you switched to might actually be the same context but rendering different content. // We should really be able to spawn new contexts for menus/prompts so that we can actually return to old ones. TEMPORARY_POPUP // This contains the command log, underneath the main contexts. EXTRAS_CONTEXT // only used by the one global context, purely for the sake of defining keybindings globally GLOBAL_CONTEXT // a display context only renders a view. It has no keybindings associated and // it cannot receive focus. DISPLAY_CONTEXT ) type ParentContexter interface { SetParentContext(Context) GetParentContext() Context } type NeedsRerenderOnWidthChangeLevel int const ( // view doesn't render differently when its width changes NEEDS_RERENDER_ON_WIDTH_CHANGE_NONE NeedsRerenderOnWidthChangeLevel = iota // view renders differently when its width changes. An example is a view // that truncates long lines to the view width, e.g. the branches view NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES // view renders differently only when the screen mode changes NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_SCREEN_MODE_CHANGES ) type IBaseContext interface { HasKeybindings ParentContexter GetKind() ContextKind GetViewName() string GetView() *gocui.View GetViewTrait() IViewTrait GetWindowName() string SetWindowName(string) GetKey() ContextKey IsFocusable() bool // if a context is transient, then it only appears via some keybinding on another // context. Until we add support for having multiple of the same context, no two // of the same transient context can appear at once meaning one might be 'stolen' // from another window. IsTransient() bool // this tells us if the view's bounds are determined by its window or if they're // determined independently. HasControlledBounds() bool // the total height of the content that the view is currently showing TotalContentHeight() int // to what extent the view needs to be rerendered when its width changes NeedsRerenderOnWidthChange() NeedsRerenderOnWidthChangeLevel // true if the view needs to be rerendered when its height changes NeedsRerenderOnHeightChange() bool // returns the desired title for the view upon activation. If there is no desired title (returns empty string), then // no title will be set Title() string GetOptionsMap() map[string]string AddKeybindingsFn(KeybindingsFn) AddMouseKeybindingsFn(MouseKeybindingsFn) ClearAllBindingsFn() // This is a bit of a hack at the moment: we currently only set an onclick function so that // our list controller can come along and wrap it in a list-specific click handler. // We'll need to think of a better way to do this. AddOnClickFn(func() error) // Likewise for the focused main view: we need this to communicate between a // side panel controller and the focused main view controller. AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error) AddOnRenderToMainFn(func()) AddOnFocusFn(func(OnFocusOpts)) AddOnFocusLostFn(func(OnFocusLostOpts)) } type Context interface { IBaseContext HandleFocus(opts OnFocusOpts) HandleFocusLost(opts OnFocusLostOpts) FocusLine() HandleRender() HandleRenderToMain() } type ISearchHistoryContext interface { Context GetSearchHistory() *utils.HistoryBuffer[string] } type IFilterableContext interface { Context IListPanelState ISearchHistoryContext SetFilter(string, bool) GetFilter() string ClearFilter() ReApplyFilter(bool) IsFiltering() bool IsFilterableContext() } type ISearchableContext interface { Context ISearchHistoryContext // These are all implemented by SearchTrait SetSearchString(string) GetSearchString() string ClearSearchString() IsSearching() bool IsSearchableContext() RenderSearchStatus(int, int) // This must be implemented by each concrete context. Return nil if not searching the model. ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition } type DiffableContext interface { Context // Returns the current diff terminals of the currently selected item. // in the case of a branch it returns both the branch and it's upstream name, // which becomes an option when you bring up the diff menu, but when you're just // flicking through branches it will be using the local branch name. GetDiffTerminals() []string // Returns the ref that should be used for creating a diff of what's // currently shown in the main view against the working directory, in order // to adjust line numbers in the diff to match the current state of the // shown file. For example, if the main view shows a range diff of commits, // we need to pass the first commit of the range. This is used by // DiffHelper.AdjustLineNumber. RefForAdjustingLineNumberInDiff() string } type IListContext interface { Context GetSelectedItemId() string GetSelectedItemIds() ([]string, int, int) IsItemVisible(item HasUrn) bool GetList() IList ViewIndexToModelIndex(int) int ModelIndexToViewIndex(int) int IsListContext() // used for type switch RangeSelectEnabled() bool RenderOnlyVisibleLines() bool } type IPatchExplorerContext interface { Context GetState() *patch_exploring.State SetState(*patch_exploring.State) GetIncludedLineIndices() []int RenderAndFocus() Render() Focus() GetContentToRender() string NavigateTo(selectedLineIdx int) GetMutex() *deadlock.Mutex IsPatchExplorerContext() // used for type switch } type IViewTrait interface { FocusPoint(yIdx int) SetRangeSelectStart(yIdx int) CancelRangeSelect() SetViewPortContent(content string) SetViewPortContentAndClearEverythingElse(content string) SetContentLineCount(lineCount int) SetContent(content string) SetFooter(value string) SetOriginX(value int) ViewPortYBounds() (int, int) ScrollLeft() ScrollRight() ScrollUp(value int) ScrollDown(value int) PageDelta() int SelectedLineIdx() int SetHighlight(bool) } type OnFocusOpts struct { ClickedWindowName string ClickedViewLineIdx int } type OnFocusLostOpts struct { NewContextKey ContextKey } type ContextKey string type KeybindingsOpts struct { GetKey func(key string) Key Config config.KeybindingConfig Guards KeybindingGuards } type ( KeybindingsFn func(opts KeybindingsOpts) []*Binding MouseKeybindingsFn func(opts KeybindingsOpts) []*gocui.ViewMouseBinding ) type HasKeybindings interface { GetKeybindings(opts KeybindingsOpts) []*Binding GetMouseKeybindings(opts KeybindingsOpts) []*gocui.ViewMouseBinding GetOnClick() func() error GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error GetOnRenderToMain() func() GetOnFocus() func(OnFocusOpts) GetOnFocusLost() func(OnFocusLostOpts) } type IController interface { HasKeybindings Context() Context } type IList interface { IListCursor Len() int GetItem(index int) HasUrn } type IListCursor interface { GetSelectedLineIdx() int SetSelectedLineIdx(value int) SetSelection(value int) MoveSelectedLine(delta int) ClampSelection() CancelRangeSelect() GetRangeStartIdx() (int, bool) GetSelectionRange() (int, int) IsSelectingRange() bool AreMultipleItemsSelected() bool ToggleStickyRange() ExpandNonStickyRange(int) } type IListPanelState interface { SetSelectedLineIdx(int) SetSelection(int) GetSelectedLineIdx() int } type ListItem interface { // ID is a hash when the item is a commit, a filename when the item is a file, 'stash@{4}' when it's a stash entry, 'my_branch' when it's a branch ID() string // Description is something we would show in a message e.g. '123as14: push blah' for a commit Description() string } type IContextMgr interface { Push(context Context, opts OnFocusOpts) Pop() Replace(context Context) Activate(context Context, opts OnFocusOpts) Current() Context CurrentStatic() Context CurrentSide() Context CurrentPopup() []Context IsCurrent(c Context) bool IsCurrentOrParent(c Context) bool ForEach(func(Context)) AllList() []IListContext AllFilterable() []IFilterableContext AllSearchable() []ISearchableContext AllPatchExplorer() []IPatchExplorerContext } lazygit-0.50.0+ds1/pkg/gui/types/keybindings.go000066400000000000000000000041711500612110400213010ustar00rootroot00000000000000package types import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" ) type Key interface{} // FIXME: find out how to get `gocui.Key | rune` // Binding - a keybinding mapping a key and modifier to a handler. The keypress // is only handled if the given view has focus, or handled globally if the view // is "" type Binding struct { ViewName string Handler func() error Key Key Modifier gocui.Modifier Description string // If defined, this is used in place of Description when showing the keybinding // in the options view at the bottom left of the screen. ShortDescription string Alternative string Tag string // e.g. 'navigation'. Used for grouping things in the cheatsheet OpensMenu bool // If true, the keybinding will appear at the bottom of the screen. // Even if set to true, the keybinding will not be displayed if it is currently // disabled. We could instead display it with a strikethrough, but there's // limited realestate to show all the keybindings we want, so we're hiding it instead. DisplayOnScreen bool // if unset, the binding will be displayed in the default color. Only applies to the keybinding // on-screen, not in the keybindings menu. DisplayStyle *style.TextStyle // to be displayed if the keybinding is highlighted from within a menu Tooltip string // Function to decide whether the command is enabled, and why. If this // returns an empty string, it is; if it returns a non-empty string, it is // disabled and we show the given text in an error message when trying to // invoke it. When left nil, the command is always enabled. Note that this // function must not do expensive calls. GetDisabledReason func() *DisabledReason } func (Binding *Binding) IsDisabled() bool { return Binding.GetDisabledReason != nil && Binding.GetDisabledReason() != nil } // A guard is a decorator which checks something before executing a handler // and potentially early-exits if some precondition hasn't been met. type Guard func(func() error) func() error type KeybindingGuards struct { OutsideFilterMode Guard NoPopupPanel Guard } lazygit-0.50.0+ds1/pkg/gui/types/modes.go000066400000000000000000000007311500612110400201000ustar00rootroot00000000000000package types import ( "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking" "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing" "github.com/jesseduffield/lazygit/pkg/gui/modes/filtering" "github.com/jesseduffield/lazygit/pkg/gui/modes/marked_base_commit" ) type Modes struct { Filtering filtering.Filtering CherryPicking *cherrypicking.CherryPicking Diffing diffing.Diffing MarkedBaseCommit marked_base_commit.MarkedBaseCommit } lazygit-0.50.0+ds1/pkg/gui/types/ref.go000066400000000000000000000003011500612110400175360ustar00rootroot00000000000000package types type Ref interface { FullRefName() string RefName() string ShortRefName() string ParentRefName() string Description() string } type RefRange struct { From Ref To Ref } lazygit-0.50.0+ds1/pkg/gui/types/refresh.go000066400000000000000000000024131500612110400204260ustar00rootroot00000000000000package types // models/views that we can refresh type RefreshableView int const ( COMMITS RefreshableView = iota REBASE_COMMITS SUB_COMMITS BRANCHES FILES STASH REFLOG TAGS REMOTES WORKTREES STATUS SUBMODULES STAGING PATCH_BUILDING MERGE_CONFLICTS COMMIT_FILES // not actually a view. Will refactor this later BISECT_INFO ) type RefreshMode int const ( SYNC RefreshMode = iota // wait until everything is done before returning ASYNC // return immediately, allowing each independent thing to update itself BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete ) type RefreshOptions struct { Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI // Normally a refresh of the branches tries to keep the same branch selected // (by name); this is usually important in case the order of branches // changes. Passing true for KeepBranchSelectionIndex suppresses this and // keeps the selection index the same. Useful after checking out a detached // head, and selecting index 0. KeepBranchSelectionIndex bool } lazygit-0.50.0+ds1/pkg/gui/types/rendering.go000066400000000000000000000036211500612110400207470ustar00rootroot00000000000000package types import ( "os/exec" ) type MainContextPair struct { Main Context Secondary Context } func NewMainContextPair(main Context, secondary Context) MainContextPair { return MainContextPair{Main: main, Secondary: secondary} } type MainViewPairs struct { Normal MainContextPair MergeConflicts MainContextPair Staging MainContextPair PatchBuilding MainContextPair } type ViewUpdateOpts struct { Title string SubTitle string Task UpdateTask } type RefreshMainOpts struct { Pair MainContextPair Main *ViewUpdateOpts Secondary *ViewUpdateOpts } type UpdateTask interface { IsUpdateTask() } type RenderStringTask struct { Str string } func (t *RenderStringTask) IsUpdateTask() {} func NewRenderStringTask(str string) *RenderStringTask { return &RenderStringTask{Str: str} } type RenderStringWithoutScrollTask struct { Str string } func (t *RenderStringWithoutScrollTask) IsUpdateTask() {} func NewRenderStringWithoutScrollTask(str string) *RenderStringWithoutScrollTask { return &RenderStringWithoutScrollTask{Str: str} } type RenderStringWithScrollTask struct { Str string OriginX int OriginY int } func (t *RenderStringWithScrollTask) IsUpdateTask() {} func NewRenderStringWithScrollTask(str string, originX int, originY int) *RenderStringWithScrollTask { return &RenderStringWithScrollTask{Str: str, OriginX: originX, OriginY: originY} } type RunCommandTask struct { Cmd *exec.Cmd Prefix string } func (t *RunCommandTask) IsUpdateTask() {} func NewRunCommandTask(cmd *exec.Cmd) *RunCommandTask { return &RunCommandTask{Cmd: cmd} } func NewRunCommandTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunCommandTask { return &RunCommandTask{Cmd: cmd, Prefix: prefix} } type RunPtyTask struct { Cmd *exec.Cmd Prefix string } func (t *RunPtyTask) IsUpdateTask() {} func NewRunPtyTask(cmd *exec.Cmd) *RunPtyTask { return &RunPtyTask{Cmd: cmd} } lazygit-0.50.0+ds1/pkg/gui/types/search_state.go000066400000000000000000000012541500612110400214370ustar00rootroot00000000000000package types type SearchType int const ( SearchTypeNone SearchType = iota // searching is where matches are highlighted but the content is not filtered down SearchTypeSearch // filter is where the list is filtered down to only matches SearchTypeFilter ) // TODO: could we remove this entirely? type SearchState struct { Context Context PrevSearchIndex int } func NewSearchState() *SearchState { return &SearchState{PrevSearchIndex: -1} } func (self *SearchState) SearchType() SearchType { switch self.Context.(type) { case IFilterableContext: return SearchTypeFilter case ISearchableContext: return SearchTypeSearch default: return SearchTypeNone } } lazygit-0.50.0+ds1/pkg/gui/types/suggestion.go000066400000000000000000000005771500612110400211700ustar00rootroot00000000000000package types type Suggestion struct { // value is the thing that we're matching on and the thing that will be submitted if you select the suggestion Value string // label is what is actually displayed so it can e.g. contain color Label string } // Conforming to the HasID interface, which is needed for list contexts func (self *Suggestion) ID() string { return self.Value } lazygit-0.50.0+ds1/pkg/gui/types/version_number.go000066400000000000000000000016541500612110400220330ustar00rootroot00000000000000package types import ( "errors" "regexp" "strconv" ) type VersionNumber struct { Major, Minor, Patch int } func (v *VersionNumber) IsOlderThan(otherVersion *VersionNumber) bool { this := v.Major*1000*1000 + v.Minor*1000 + v.Patch other := otherVersion.Major*1000*1000 + otherVersion.Minor*1000 + otherVersion.Patch return this < other } func ParseVersionNumber(versionStr string) (*VersionNumber, error) { re := regexp.MustCompile(`^v?(\d+)\.(\d+)(?:\.(\d+))?$`) matches := re.FindStringSubmatch(versionStr) if matches == nil { return nil, errors.New("unexpected version format: " + versionStr) } v := &VersionNumber{} var err error if v.Major, err = strconv.Atoi(matches[1]); err != nil { return nil, err } if v.Minor, err = strconv.Atoi(matches[2]); err != nil { return nil, err } if len(matches[3]) > 0 { if v.Patch, err = strconv.Atoi(matches[3]); err != nil { return nil, err } } return v, nil } lazygit-0.50.0+ds1/pkg/gui/types/version_number_test.go000066400000000000000000000026461500612110400230740ustar00rootroot00000000000000package types import ( "errors" "testing" "github.com/stretchr/testify/assert" ) func TestParseVersionNumber(t *testing.T) { tests := []struct { versionStr string expected *VersionNumber err error }{ { versionStr: "1.2.3", expected: &VersionNumber{ Major: 1, Minor: 2, Patch: 3, }, err: nil, }, { versionStr: "v1.2.3", expected: &VersionNumber{ Major: 1, Minor: 2, Patch: 3, }, err: nil, }, { versionStr: "12.34.56", expected: &VersionNumber{ Major: 12, Minor: 34, Patch: 56, }, err: nil, }, { versionStr: "1.2", expected: &VersionNumber{ Major: 1, Minor: 2, Patch: 0, }, err: nil, }, { versionStr: "1", expected: nil, err: errors.New("unexpected version format: 1"), }, { versionStr: "invalid", expected: nil, err: errors.New("unexpected version format: invalid"), }, { versionStr: "junk_before 1.2.3", expected: nil, err: errors.New("unexpected version format: junk_before 1.2.3"), }, { versionStr: "1.2.3 junk_after", expected: nil, err: errors.New("unexpected version format: 1.2.3 junk_after"), }, } for _, test := range tests { t.Run(test.versionStr, func(t *testing.T) { actual, err := ParseVersionNumber(test.versionStr) assert.Equal(t, test.expected, actual) assert.Equal(t, test.err, err) }) } } lazygit-0.50.0+ds1/pkg/gui/types/views.go000066400000000000000000000023151500612110400201260ustar00rootroot00000000000000package types import "github.com/jesseduffield/gocui" type Views struct { Status *gocui.View Submodules *gocui.View Files *gocui.View Branches *gocui.View Remotes *gocui.View Worktrees *gocui.View Tags *gocui.View RemoteBranches *gocui.View ReflogCommits *gocui.View Commits *gocui.View Stash *gocui.View Main *gocui.View Secondary *gocui.View Staging *gocui.View StagingSecondary *gocui.View PatchBuilding *gocui.View PatchBuildingSecondary *gocui.View MergeConflicts *gocui.View Options *gocui.View Confirmation *gocui.View Menu *gocui.View CommitMessage *gocui.View CommitDescription *gocui.View CommitFiles *gocui.View SubCommits *gocui.View Information *gocui.View AppStatus *gocui.View Search *gocui.View SearchPrefix *gocui.View StatusSpacer1 *gocui.View StatusSpacer2 *gocui.View Limit *gocui.View Suggestions *gocui.View Tooltip *gocui.View Extras *gocui.View // for playing the easter egg snake game Snake *gocui.View } lazygit-0.50.0+ds1/pkg/gui/view_helpers.go000066400000000000000000000110061500612110400203160ustar00rootroot00000000000000package gui import ( "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/spkg/bom" ) func (gui *Gui) resetViewOrigin(v *gocui.View) { v.SetCursor(0, 0) v.SetOrigin(0, 0) } // Returns the number of lines that we should read initially from a cmd task so // that the scrollbar has the correct size, along with the number of lines after // which the view is filled and we can do a first refresh. func (gui *Gui) linesToReadFromCmdTask(v *gocui.View) tasks.LinesToRead { height := v.InnerHeight() oy := v.OriginY() linesForFirstRefresh := height + oy + 10 // We want to read as many lines initially as necessary to let the // scrollbar go to its minimum height, so that the scrollbar thumb doesn't // change size as you scroll down. minScrollbarHeight := 1 linesToReadForAccurateScrollbar := height*(height-1)/minScrollbarHeight + oy // However, cap it at some arbitrary max limit, so that we don't get // performance problems for huge monitors or tiny font sizes if linesToReadForAccurateScrollbar > 5000 { linesToReadForAccurateScrollbar = 5000 } return tasks.LinesToRead{ Total: linesToReadForAccurateScrollbar, InitialRefreshAfter: linesForFirstRefresh, } } func (gui *Gui) cleanString(s string) string { output := string(bom.Clean([]byte(s))) return utils.NormalizeLinefeeds(output) } func (gui *Gui) setViewContent(v *gocui.View, s string) { v.SetContent(gui.cleanString(s)) } func (gui *Gui) currentViewName() string { currentView := gui.g.CurrentView() if currentView == nil { return "" } return currentView.Name() } func (gui *Gui) onViewTabClick(windowName string, tabIndex int) error { tabs := gui.viewTabMap()[windowName] if len(tabs) == 0 { return nil } viewName := tabs[tabIndex].ViewName context, ok := gui.helpers.View.ContextForView(viewName) if !ok { return nil } gui.c.Context().Push(context, types.OnFocusOpts{}) return nil } func (gui *Gui) handleNextTab() error { view := getTabbedView(gui) if view == nil { return nil } for _, context := range gui.State.Contexts.Flatten() { if context.GetViewName() == view.Name() { return gui.onViewTabClick( context.GetWindowName(), utils.ModuloWithWrap(view.TabIndex+1, len(view.Tabs)), ) } } return nil } func (gui *Gui) handlePrevTab() error { view := getTabbedView(gui) if view == nil { return nil } for _, context := range gui.State.Contexts.Flatten() { if context.GetViewName() == view.Name() { return gui.onViewTabClick( context.GetWindowName(), utils.ModuloWithWrap(view.TabIndex-1, len(view.Tabs)), ) } } return nil } func getTabbedView(gui *Gui) *gocui.View { // It safe assumption that only static contexts have tabs context := gui.c.Context().CurrentStatic() view, _ := gui.g.View(context.GetViewName()) return view } func (gui *Gui) render() { gui.c.OnUIThread(func() error { return nil }) } // postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed // if the context's view is set to another context we do nothing. // if the context's view is the current view we trigger a focus; re-selecting the current item. func (gui *Gui) postRefreshUpdate(c types.Context) { t := time.Now() defer func() { gui.Log.Infof("postRefreshUpdate for %s took %s", c.GetKey(), time.Since(t)) }() c.HandleRender() if gui.currentViewName() == c.GetViewName() { c.HandleFocus(types.OnFocusOpts{}) } else { // The FocusLine call is included in the HandleFocus method which we // call for focused views above; but we need to call it here for // non-focused views to ensure that an inactive selection is painted // correctly, and that integration tests see the up to date selection // state. c.FocusLine() currentCtx := gui.State.ContextMgr.Current() if currentCtx.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY || currentCtx.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY { // Searching can't cope well with the view being updated while it is being searched. // We might be able to fix the problems with this, but it doesn't seem easy, so for now // just don't rerender the view while searching, on the assumption that users will probably // either search or change their data, but not both at the same time. if !currentCtx.GetView().IsSearching() { parentCtx := currentCtx.GetParentContext() parentCtx.HandleRenderToMain() } } } } lazygit-0.50.0+ds1/pkg/gui/views.go000066400000000000000000000223061500612110400167640ustar00rootroot00000000000000package gui import ( "fmt" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" "golang.org/x/exp/slices" ) type viewNameMapping struct { viewPtr **gocui.View name string } func (gui *Gui) orderedViews() []*gocui.View { return lo.Map(gui.orderedViewNameMappings(), func(v viewNameMapping, _ int) *gocui.View { return *v.viewPtr }) } func (gui *Gui) orderedViewNameMappings() []viewNameMapping { return []viewNameMapping{ // first layer. Ordering within this layer does not matter because there are // no overlapping views {viewPtr: &gui.Views.Status, name: "status"}, {viewPtr: &gui.Views.Snake, name: "snake"}, {viewPtr: &gui.Views.Submodules, name: "submodules"}, {viewPtr: &gui.Views.Worktrees, name: "worktrees"}, {viewPtr: &gui.Views.Files, name: "files"}, {viewPtr: &gui.Views.Tags, name: "tags"}, {viewPtr: &gui.Views.Remotes, name: "remotes"}, {viewPtr: &gui.Views.Branches, name: "localBranches"}, {viewPtr: &gui.Views.RemoteBranches, name: "remoteBranches"}, {viewPtr: &gui.Views.ReflogCommits, name: "reflogCommits"}, {viewPtr: &gui.Views.Commits, name: "commits"}, {viewPtr: &gui.Views.Stash, name: "stash"}, {viewPtr: &gui.Views.SubCommits, name: "subCommits"}, {viewPtr: &gui.Views.CommitFiles, name: "commitFiles"}, {viewPtr: &gui.Views.Staging, name: "staging"}, {viewPtr: &gui.Views.StagingSecondary, name: "stagingSecondary"}, {viewPtr: &gui.Views.PatchBuilding, name: "patchBuilding"}, {viewPtr: &gui.Views.PatchBuildingSecondary, name: "patchBuildingSecondary"}, {viewPtr: &gui.Views.MergeConflicts, name: "mergeConflicts"}, {viewPtr: &gui.Views.Secondary, name: "secondary"}, {viewPtr: &gui.Views.Main, name: "main"}, {viewPtr: &gui.Views.Extras, name: "extras"}, // bottom line {viewPtr: &gui.Views.Options, name: "options"}, {viewPtr: &gui.Views.AppStatus, name: "appStatus"}, {viewPtr: &gui.Views.Information, name: "information"}, {viewPtr: &gui.Views.Search, name: "search"}, // this view shows either the "Search:" prompt when searching, or the "Filter:" prompt when filtering {viewPtr: &gui.Views.SearchPrefix, name: "searchPrefix"}, // these views contain one space, and are used as spacers between the various views in the bottom line {viewPtr: &gui.Views.StatusSpacer1, name: "statusSpacer1"}, {viewPtr: &gui.Views.StatusSpacer2, name: "statusSpacer2"}, // popups. {viewPtr: &gui.Views.CommitMessage, name: "commitMessage"}, {viewPtr: &gui.Views.CommitDescription, name: "commitDescription"}, {viewPtr: &gui.Views.Menu, name: "menu"}, {viewPtr: &gui.Views.Suggestions, name: "suggestions"}, {viewPtr: &gui.Views.Confirmation, name: "confirmation"}, {viewPtr: &gui.Views.Tooltip, name: "tooltip"}, // this guy will cover everything else when it appears {viewPtr: &gui.Views.Limit, name: "limit"}, } } func (gui *Gui) createAllViews() error { var err error for _, mapping := range gui.orderedViewNameMappings() { *mapping.viewPtr, err = gui.prepareView(mapping.name) if err != nil && !gocui.IsUnknownView(err) { return err } } gui.Views.Options.Frame = false gui.Views.SearchPrefix.BgColor = gocui.ColorDefault gui.Views.SearchPrefix.FgColor = gocui.ColorCyan gui.Views.SearchPrefix.Frame = false gui.Views.StatusSpacer1.Frame = false gui.Views.StatusSpacer2.Frame = false gui.Views.Search.BgColor = gocui.ColorDefault gui.Views.Search.FgColor = gocui.ColorCyan gui.Views.Search.Editable = true gui.Views.Search.Frame = false gui.Views.Search.Editor = gocui.EditorFunc(gui.searchEditor) for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.Staging, gui.Views.StagingSecondary, gui.Views.PatchBuilding, gui.Views.PatchBuildingSecondary, gui.Views.MergeConflicts} { view.Wrap = true view.IgnoreCarriageReturns = true view.UnderlineHyperLinksOnlyOnHover = true view.AutoRenderHyperLinks = true } gui.Views.Staging.Wrap = true gui.Views.StagingSecondary.Wrap = true gui.Views.PatchBuilding.Wrap = true gui.Views.PatchBuildingSecondary.Wrap = true gui.Views.MergeConflicts.Wrap = false gui.Views.Limit.Wrap = true gui.Views.AppStatus.BgColor = gocui.ColorDefault gui.Views.AppStatus.FgColor = gocui.ColorCyan gui.Views.AppStatus.Visible = false gui.Views.AppStatus.Frame = false gui.Views.CommitMessage.Visible = false gui.Views.CommitMessage.Editable = true gui.Views.CommitMessage.Editor = gocui.EditorFunc(gui.commitMessageEditor) gui.Views.CommitDescription.Visible = false gui.Views.CommitDescription.Editable = true gui.Views.CommitDescription.Editor = gocui.EditorFunc(gui.commitDescriptionEditor) gui.Views.Confirmation.Visible = false gui.Views.Confirmation.Editor = gocui.EditorFunc(gui.promptEditor) gui.Views.Confirmation.AutoRenderHyperLinks = true gui.Views.Suggestions.Visible = false gui.Views.Menu.Visible = false gui.Views.Tooltip.Visible = false gui.Views.Tooltip.AutoRenderHyperLinks = true gui.Views.Information.BgColor = gocui.ColorDefault gui.Views.Information.FgColor = gocui.ColorGreen gui.Views.Information.Frame = false gui.Views.Extras.Autoscroll = true gui.Views.Extras.Wrap = true gui.Views.Extras.AutoRenderHyperLinks = true gui.Views.Snake.FgColor = gocui.ColorGreen return nil } func (gui *Gui) configureViewProperties() { frameRunes := []rune{'─', '│', '┌', '┐', '└', '┘'} switch gui.c.UserConfig().Gui.Border { case "double": frameRunes = []rune{'═', '║', '╔', '╗', '╚', '╝'} case "rounded": frameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} case "hidden": frameRunes = []rune{' ', ' ', ' ', ' ', ' ', ' '} } for _, mapping := range gui.orderedViewNameMappings() { (*mapping.viewPtr).FrameRunes = frameRunes (*mapping.viewPtr).BgColor = gui.g.BgColor (*mapping.viewPtr).FgColor = theme.GocuiDefaultTextColor (*mapping.viewPtr).SelBgColor = theme.GocuiSelectedLineBgColor (*mapping.viewPtr).SelFgColor = gui.g.SelFgColor (*mapping.viewPtr).InactiveViewSelBgColor = theme.GocuiInactiveViewSelectedLineBgColor } gui.c.SetViewContent(gui.Views.SearchPrefix, gui.c.Tr.SearchPrefix) gui.Views.Stash.Title = gui.c.Tr.StashTitle gui.Views.Commits.Title = gui.c.Tr.CommitsTitle gui.Views.CommitFiles.Title = gui.c.Tr.CommitFiles gui.Views.Branches.Title = gui.c.Tr.BranchesTitle gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle gui.Views.Worktrees.Title = gui.c.Tr.WorktreesTitle gui.Views.Tags.Title = gui.c.Tr.TagsTitle gui.Views.Files.Title = gui.c.Tr.FilesTitle gui.Views.PatchBuilding.Title = gui.c.Tr.Patch gui.Views.PatchBuildingSecondary.Title = gui.c.Tr.CustomPatch gui.Views.MergeConflicts.Title = gui.c.Tr.MergeConflictsTitle gui.Views.Limit.Title = gui.c.Tr.NotEnoughSpace gui.Views.Status.Title = gui.c.Tr.StatusTitle gui.Views.Staging.Title = gui.c.Tr.UnstagedChanges gui.Views.StagingSecondary.Title = gui.c.Tr.StagedChanges gui.Views.CommitMessage.Title = gui.c.Tr.CommitSummary gui.Views.CommitDescription.Title = gui.c.Tr.CommitDescriptionTitle gui.Views.Extras.Title = gui.c.Tr.CommandLog gui.Views.Snake.Title = gui.c.Tr.SnakeTitle for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.Staging, gui.Views.StagingSecondary, gui.Views.PatchBuilding, gui.Views.PatchBuildingSecondary, gui.Views.MergeConflicts} { view.Title = gui.c.Tr.DiffTitle view.CanScrollPastBottom = gui.c.UserConfig().Gui.ScrollPastBottom view.TabWidth = gui.c.UserConfig().Gui.TabWidth } gui.Views.CommitDescription.FgColor = theme.GocuiDefaultTextColor gui.Views.CommitDescription.TextArea.AutoWrap = gui.c.UserConfig().Git.Commit.AutoWrapCommitMessage gui.Views.CommitDescription.TextArea.AutoWrapWidth = gui.c.UserConfig().Git.Commit.AutoWrapWidth if gui.c.UserConfig().Gui.ShowPanelJumps { jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock jumpLabels := lo.Map(jumpBindings, func(binding string, _ int) string { return fmt.Sprintf("[%s]", binding) }) gui.Views.Status.TitlePrefix = jumpLabels[0] gui.Views.Files.TitlePrefix = jumpLabels[1] gui.Views.Worktrees.TitlePrefix = jumpLabels[1] gui.Views.Submodules.TitlePrefix = jumpLabels[1] gui.Views.Branches.TitlePrefix = jumpLabels[2] gui.Views.Remotes.TitlePrefix = jumpLabels[2] gui.Views.Tags.TitlePrefix = jumpLabels[2] gui.Views.Commits.TitlePrefix = jumpLabels[3] gui.Views.ReflogCommits.TitlePrefix = jumpLabels[3] gui.Views.Stash.TitlePrefix = jumpLabels[4] } else { gui.Views.Status.TitlePrefix = "" gui.Views.Files.TitlePrefix = "" gui.Views.Worktrees.TitlePrefix = "" gui.Views.Submodules.TitlePrefix = "" gui.Views.Branches.TitlePrefix = "" gui.Views.Remotes.TitlePrefix = "" gui.Views.Tags.TitlePrefix = "" gui.Views.Commits.TitlePrefix = "" gui.Views.ReflogCommits.TitlePrefix = "" gui.Views.Stash.TitlePrefix = "" } for _, view := range gui.g.Views() { // if the view is in our mapping, we'll set the tabs and the tab index for _, values := range gui.viewTabMap() { index := slices.IndexFunc(values, func(tabContext context.TabView) bool { return tabContext.ViewName == view.Name() }) if index != -1 { view.Tabs = lo.Map(values, func(tabContext context.TabView, _ int) string { return tabContext.Tab }) view.TabIndex = index } } } } lazygit-0.50.0+ds1/pkg/i18n/000077500000000000000000000000001500612110400152705ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/i18n/english.go000066400000000000000000004210571500612110400172610ustar00rootroot00000000000000/* Todo list when making a new translation - Copy this file and rename it to the language you want to translate to like someLanguage.go - Change the EnglishTranslationSet() name to the language you want to translate to like SomeLanguageTranslationSet() - Add an entry of someLanguage in GetTranslationSets() - Remove this todo and the about section */ package i18n type TranslationSet struct { NotEnoughSpace string DiffTitle string FilesTitle string BranchesTitle string CommitsTitle string StashTitle string SnakeTitle string EasterEgg string UnstagedChanges string StagedChanges string MainTitle string StagingTitle string MergingTitle string SquashMergeUncommittedTitle string SquashMergeCommittedTitle string SquashMergeUncommitted string SquashMergeCommitted string RegularMergeTooltip string NormalTitle string LogTitle string CommitSummary string CredentialsUsername string CredentialsPassword string CredentialsPassphrase string CredentialsPIN string CredentialsToken string PassUnameWrong string Commit string CommitTooltip string AmendLastCommit string AmendLastCommitTitle string SureToAmend string NoCommitToAmend string CommitChangesWithEditor string FindBaseCommitForFixup string FindBaseCommitForFixupTooltip string NoBaseCommitsFound string MultipleBaseCommitsFoundStaged string MultipleBaseCommitsFoundUnstaged string BaseCommitIsAlreadyOnMainBranch string BaseCommitIsNotInCurrentView string HunksWithOnlyAddedLinesWarning string StatusTitle string GlobalTitle string Menu string Execute string Stage string StageTooltip string ToggleStagedAll string ToggleStagedAllTooltip string ToggleTreeView string ToggleTreeViewTooltip string OpenDiffTool string OpenMergeTool string OpenMergeToolTooltip string Refresh string RefreshTooltip string Push string Pull string PushTooltip string PullTooltip string Scroll string FileFilter string CopyToClipboardMenu string CopyFileName string CopyRelativeFilePath string CopyAbsoluteFilePath string CopyFileDiffTooltip string CopySelectedDiff string CopyAllFilesDiff string CopyFileContent string NoContentToCopyError string FileNameCopiedToast string FilePathCopiedToast string FileDiffCopiedToast string AllFilesDiffCopiedToast string FileContentCopiedToast string FilterStagedFiles string FilterUnstagedFiles string FilterTrackedFiles string FilterUntrackedFiles string NoFilter string FilterLabelStagedFiles string FilterLabelUnstagedFiles string FilterLabelTrackedFiles string FilterLabelUntrackedFiles string FilterLabelConflictingFiles string MergeConflictsTitle string MergeConflictDescription_DD string MergeConflictDescription_AU string MergeConflictDescription_UA string MergeConflictDescription_DU string MergeConflictDescription_UD string MergeConflictIncomingDiff string MergeConflictCurrentDiff string MergeConflictPressEnterToResolve string MergeConflictKeepFile string MergeConflictDeleteFile string Checkout string CheckoutTooltip string CantCheckoutBranchWhilePulling string TagCheckoutTooltip string RemoteBranchCheckoutTooltip string CantPullOrPushSameBranchTwice string NoChangedFiles string SoftReset string AlreadyCheckedOutBranch string SureForceCheckout string ForceCheckoutBranch string BranchName string NewBranchNameBranchOff string CantDeleteCheckOutBranch string DeleteBranchTitle string DeleteBranchesTitle string DeleteLocalBranch string DeleteLocalBranches string DeleteRemoteBranchOption string DeleteRemoteBranchPrompt string DeleteRemoteBranchesPrompt string DeleteLocalAndRemoteBranchPrompt string DeleteLocalAndRemoteBranchesPrompt string ForceDeleteBranchTitle string ForceDeleteBranchMessage string ForceDeleteBranchesMessage string RebaseBranch string RebaseBranchTooltip string CantRebaseOntoSelf string CantMergeBranchIntoItself string ForceCheckout string ForceCheckoutTooltip string CheckoutByName string CheckoutByNameTooltip string RemoteBranchCheckoutTitle string RemoteBranchCheckoutPrompt string CheckoutTypeNewBranch string CheckoutTypeNewBranchTooltip string CheckoutTypeDetachedHead string CheckoutTypeDetachedHeadTooltip string NewBranch string NewBranchFromStashTooltip string MoveCommitsToNewBranch string MoveCommitsToNewBranchTooltip string MoveCommitsToNewBranchFromMainPrompt string MoveCommitsToNewBranchMenuPrompt string MoveCommitsToNewBranchFromBaseItem string MoveCommitsToNewBranchStackedItem string CannotMoveCommitsFromDetachedHead string CannotMoveCommitsNoUpstream string CannotMoveCommitsBehindUpstream string CannotMoveCommitsNoUnpushedCommits string NoBranchesThisRepo string CommitWithoutMessageErr string Close string CloseCancel string Confirm string Quit string SquashTooltip string CannotSquashOrFixupFirstCommit string CannotSquashOrFixupMergeCommit string Fixup string FixupTooltip string SureFixupThisCommit string SureSquashThisCommit string Squash string SquashMerge string PickCommitTooltip string Pick string CantPickDisabledReason string Edit string RevertCommit string Revert string RevertCommitTooltip string Reword string CommitRewordTooltip string DropCommit string DropCommitTooltip string MoveDownCommit string MoveUpCommit string CannotMoveAnyFurther string CannotMoveMergeCommit string EditCommit string EditCommitTooltip string AmendCommitTooltip string Amend string ResetAuthor string ResetAuthorTooltip string SetAuthor string SetAuthorTooltip string AddCoAuthor string AmendCommitAttribute string AmendCommitAttributeTooltip string SetAuthorPromptTitle string AddCoAuthorPromptTitle string AddCoAuthorTooltip string SureResetCommitAuthor string RewordCommitEditor string NoCommitsThisBranch string UpdateRefHere string ExecCommandHere string Error string Undo string UndoReflog string RedoReflog string UndoTooltip string RedoTooltip string UndoMergeResolveTooltip string DiscardAllTooltip string DiscardUnstagedTooltip string DiscardUnstagedDisabled string Pop string StashPopTooltip string Drop string StashDropTooltip string Apply string StashApplyTooltip string NoStashEntries string StashDrop string SureDropStashEntry string StashPop string SurePopStashEntry string StashApply string SureApplyStashEntry string NoTrackedStagedFilesStash string NoFilesToStash string StashChanges string RenameStash string RenameStashPrompt string OpenConfig string EditConfig string ForcePush string ForcePushPrompt string ForcePushDisabled string UpdatesRejected string UpdatesRejectedAndForcePushDisabled string CheckForUpdate string CheckingForUpdates string UpdateAvailableTitle string UpdateAvailable string UpdateInProgressWaitingStatus string UpdateCompletedTitle string UpdateCompleted string FailedToRetrieveLatestVersionErr string OnLatestVersionErr string MajorVersionErr string CouldNotFindBinaryErr string UpdateFailedErr string ConfirmQuitDuringUpdateTitle string ConfirmQuitDuringUpdate string MergeToolTitle string MergeToolPrompt string IntroPopupMessage string DeprecatedEditConfigWarning string NonReloadableConfigWarningTitle string NonReloadableConfigWarning string GitconfigParseErr string EditFile string EditFileTooltip string OpenFile string OpenFileTooltip string OpenInEditor string IgnoreFile string ExcludeFile string RefreshFiles string FocusMainView string Merge string RegularMerge string MergeBranchTooltip string ConfirmQuit string SwitchRepo string AllBranchesLogGraph string UnsupportedGitService string CopyPullRequestURL string NoBranchOnRemote string Fetch string FetchTooltip string CollapseAll string CollapseAllTooltip string ExpandAll string ExpandAllTooltip string DisabledInFlatView string FileEnter string FileEnterTooltip string FileStagingRequirements string StageSelectionTooltip string DiscardSelection string DiscardSelectionTooltip string ToggleSelectHunk string ToggleSelectHunkTooltip string ToggleSelectionForPatch string EditHunk string EditHunkTooltip string ToggleStagingView string ToggleStagingViewTooltip string ReturnToFilesPanel string FastForward string FastForwardTooltip string FastForwarding string FoundConflictsTitle string ViewConflictsMenuItem string AbortMenuItem string PickHunk string PickAllHunks string ViewMergeRebaseOptions string ViewMergeRebaseOptionsTooltip string ViewMergeOptions string ViewRebaseOptions string ViewCherryPickOptions string ViewRevertOptions string NotMergingOrRebasing string AlreadyRebasing string RecentRepos string MergeOptionsTitle string RebaseOptionsTitle string CherryPickOptionsTitle string RevertOptionsTitle string CommitSummaryTitle string CommitDescriptionTitle string CommitDescriptionSubTitle string CommitDescriptionFooter string CommitHooksDisabledSubTitle string LocalBranchesTitle string SearchTitle string TagsTitle string MenuTitle string CommitMenuTitle string RemotesTitle string RemoteBranchesTitle string PatchBuildingTitle string InformationTitle string SecondaryTitle string ReflogCommitsTitle string ConflictsResolved string Continue string UnstagedFilesAfterConflictsResolved string RebasingTitle string RebasingFromBaseCommitTitle string SimpleRebase string InteractiveRebase string RebaseOntoBaseBranch string InteractiveRebaseTooltip string RebaseOntoBaseBranchTooltip string MustSelectTodoCommits string FwdNoUpstream string FwdNoLocalUpstream string FwdCommitsToPush string PullRequestNoUpstream string ErrorOccurred string NoRoom string ConflictLabel string PendingRebaseTodosSectionHeader string PendingCherryPicksSectionHeader string PendingRevertsSectionHeader string CommitsSectionHeader string YouDied string RewordNotSupported string ChangingThisActionIsNotAllowed string NotAllowedMidCherryPickOrRevert string DroppingMergeRequiresSingleSelection string CherryPickCopy string CherryPickCopyTooltip string CherryPickCopyRangeTooltip string PasteCommits string SureCherryPick string CherryPick string CannotCherryPickNonCommit string CannotCherryPickMergeCommit string Donate string AskQuestion string PrevLine string NextLine string PrevHunk string NextHunk string PrevConflict string NextConflict string SelectPrevHunk string SelectNextHunk string ScrollDown string ScrollUp string ScrollUpMainWindow string ScrollDownMainWindow string AmendCommitTitle string AmendCommitPrompt string AmendCommitWithConflictsMenuPrompt string AmendCommitWithConflictsContinue string AmendCommitWithConflictsAmend string DropCommitTitle string DropCommitPrompt string DropUpdateRefPrompt string DropMergeCommitPrompt string PullingStatus string PushingStatus string FetchingStatus string SquashingStatus string FixingStatus string DeletingStatus string DroppingStatus string MovingStatus string RebasingStatus string MergingStatus string LowercaseRebasingStatus string LowercaseMergingStatus string LowercaseCherryPickingStatus string LowercaseRevertingStatus string AmendingStatus string CherryPickingStatus string UndoingStatus string RedoingStatus string CheckingOutStatus string CommittingStatus string RewordingStatus string RevertingStatus string CreatingFixupCommitStatus string MovingCommitsToNewBranchStatus string CommitFiles string SubCommitsDynamicTitle string CommitFilesDynamicTitle string RemoteBranchesDynamicTitle string ViewItemFiles string ViewItemFilesTooltip string CommitFilesTitle string CheckoutCommitFileTooltip string CanOnlyDiscardFromLocalCommits string Remove string DiscardOldFileChangeTooltip string DiscardFileChangesTitle string DiscardFileChangesPrompt string DisabledForGPG string CreateRepo string BareRepo string InitialBranch string NoRecentRepositories string IncorrectNotARepository string AutoStashTitle string AutoStashPrompt string StashPrefix string Discard string DiscardChangesTitle string DiscardFileChangesTooltip string Cancel string DiscardAllChanges string DiscardUnstagedChanges string DiscardAllChangesToAllFiles string DiscardAnyUnstagedChanges string DiscardUntrackedFiles string DiscardStagedChanges string HardReset string BranchDeleteTooltip string TagDeleteTooltip string Delete string Reset string ResetTooltip string ViewResetOptions string FileResetOptionsTooltip string CreateFixupCommit string CreateFixupCommitTooltip string CreateAmendCommit string FixupMenu_Fixup string FixupMenu_FixupTooltip string FixupMenu_AmendWithChanges string FixupMenu_AmendWithChangesTooltip string FixupMenu_AmendWithoutChanges string FixupMenu_AmendWithoutChangesTooltip string SquashAboveCommitsTooltip string SquashCommitsAboveSelectedTooltip string SquashCommitsInCurrentBranchTooltip string SquashAboveCommits string SquashCommitsInCurrentBranch string SquashCommitsAboveSelectedCommit string CannotSquashCommitsInCurrentBranch string ExecuteShellCommand string ExecuteShellCommandTooltip string ShellCommand string CommitChangesWithoutHook string ResetTo string ResetSoftTooltip string ResetMixedTooltip string ResetHardTooltip string PressEnterToReturn string ViewStashOptions string ViewStashOptionsTooltip string Stash string StashTooltip string StashAllChanges string StashStagedChanges string StashAllChangesKeepIndex string StashUnstagedChanges string StashIncludeUntrackedChanges string StashOptions string NotARepository string WorkingDirectoryDoesNotExist string Jump string ScrollLeftRight string ScrollLeft string ScrollRight string DiscardPatch string DiscardPatchConfirm string CantPatchWhileRebasingError string ToggleAddToPatch string ToggleAddToPatchTooltip string ToggleAllInPatch string ToggleAllInPatchTooltip string UpdatingPatch string ViewPatchOptions string PatchOptionsTitle string NoPatchError string EmptyPatchError string EnterCommitFile string EnterCommitFileTooltip string ExitCustomPatchBuilder string ExitFocusedMainView string EnterUpstream string InvalidUpstream string ReturnToRemotesList string NewRemote string NewRemoteName string NewRemoteUrl string ViewBranches string EditRemoteName string EditRemoteUrl string RemoveRemote string RemoveRemoteTooltip string RemoveRemotePrompt string DeleteRemoteBranch string DeleteRemoteBranches string DeleteRemoteBranchTooltip string DeleteLocalAndRemoteBranch string DeleteLocalAndRemoteBranches string SetAsUpstream string SetAsUpstreamTooltip string SetUpstream string UnsetUpstream string ViewDivergenceFromUpstream string ViewDivergenceFromBaseBranch string CouldNotDetermineBaseBranch string DivergenceSectionHeaderLocal string DivergenceSectionHeaderRemote string ViewUpstreamResetOptions string ViewUpstreamResetOptionsTooltip string ViewUpstreamRebaseOptions string ViewUpstreamRebaseOptionsTooltip string UpstreamGenericName string SetUpstreamTitle string SetUpstreamMessage string EditRemoteTooltip string TagCommit string TagCommitTooltip string TagMenuTitle string TagNameTitle string TagMessageTitle string LightweightTag string AnnotatedTag string DeleteTagTitle string DeleteLocalTag string DeleteRemoteTag string DeleteLocalAndRemoteTag string SelectRemoteTagUpstream string DeleteRemoteTagPrompt string DeleteLocalAndRemoteTagPrompt string RemoteTagDeletedMessage string PushTagTitle string PushTag string PushTagTooltip string NewTag string NewTagTooltip string CreatingTag string ForceTag string ForceTagPrompt string FetchRemoteTooltip string FetchingRemoteStatus string CheckoutCommit string CheckoutCommitTooltip string NoBranchesFoundAtCommitTooltip string SureCheckoutThisCommit string GitFlowOptions string NotAGitFlowBranch string NewBranchNamePrompt string IgnoreTracked string ExcludeTracked string IgnoreTrackedPrompt string ExcludeTrackedPrompt string ViewResetToUpstreamOptions string NextScreenMode string PrevScreenMode string StartSearch string StartFilter string Panel string Keybindings string KeybindingsLegend string KeybindingsMenuSectionLocal string KeybindingsMenuSectionGlobal string KeybindingsMenuSectionNavigation string RenameBranch string Upstream string UpstreamTooltip string BranchUpstreamOptionsTitle string ViewBranchUpstreamOptions string ViewBranchUpstreamOptionsTooltip string UpstreamNotSetError string UpstreamsNotSetError string NewGitFlowBranchPrompt string RenameBranchWarning string OpenKeybindingsMenu string ResetCherryPick string NextTab string PrevTab string CantUndoWhileRebasing string CantRedoWhileRebasing string MustStashWarning string MustStashTitle string ConfirmationTitle string PrevPage string NextPage string GotoTop string GotoBottom string FilteringBy string ResetInParentheses string OpenFilteringMenu string OpenFilteringMenuTooltip string FilterBy string ExitFilterMode string FilterPathOption string FilterAuthorOption string EnterFileName string EnterAuthor string FilteringMenuTitle string WillCancelExistingFilterTooltip string MustExitFilterModeTitle string MustExitFilterModePrompt string Diff string EnterRefToDiff string EnterRefName string ExitDiffMode string DiffingMenuTitle string SwapDiff string ViewDiffingOptions string ViewDiffingOptionsTooltip string OpenCommandLogMenu string OpenCommandLogMenuTooltip string ShowingGitDiff string ShowingDiffForRange string CommitDiff string CopyCommitHashToClipboard string CommitHash string CommitURL string CopyCommitMessageToClipboard string PasteCommitMessageFromClipboard string SurePasteCommitMessage string CommitMessage string CommitMessageBody string CommitSubject string CommitAuthor string CommitTags string CopyCommitAttributeToClipboard string CopyCommitAttributeToClipboardTooltip string CopyBranchNameToClipboard string CopyTagToClipboard string CopyPathToClipboard string CommitPrefixPatternError string CopySelectedTextToClipboard string NoFilesStagedTitle string NoFilesStagedPrompt string BranchNotFoundTitle string BranchNotFoundPrompt string BranchUnknown string DiscardChangeTitle string DiscardChangePrompt string CreateNewBranchFromCommit string BuildingPatch string ViewCommits string MinGitVersionError string RunningCustomCommandStatus string SubmoduleStashAndReset string AndResetSubmodules string EnterSubmoduleTooltip string Enter string CopySubmoduleNameToClipboard string RemoveSubmodule string RemoveSubmoduleTooltip string RemoveSubmodulePrompt string ResettingSubmoduleStatus string NewSubmoduleName string NewSubmoduleUrl string NewSubmodulePath string NewSubmodule string AddingSubmoduleStatus string UpdateSubmoduleUrl string UpdatingSubmoduleUrlStatus string EditSubmoduleUrl string InitializingSubmoduleStatus string InitSubmoduleTooltip string Update string Initialize string SubmoduleUpdateTooltip string UpdatingSubmoduleStatus string BulkInitSubmodules string BulkUpdateSubmodules string BulkDeinitSubmodules string BulkUpdateRecursiveSubmodules string ViewBulkSubmoduleOptions string BulkSubmoduleOptions string RunningCommand string SubCommitsTitle string SubmodulesTitle string NavigationTitle string SuggestionsCheatsheetTitle string // Unlike the cheatsheet title above, the real suggestions title has a little message saying press tab to focus SuggestionsTitle string SuggestionsSubtitle string ExtrasTitle string PushingTagStatus string PullRequestURLCopiedToClipboard string CommitDiffCopiedToClipboard string CommitURLCopiedToClipboard string CommitMessageCopiedToClipboard string CommitMessageBodyCopiedToClipboard string CommitSubjectCopiedToClipboard string CommitAuthorCopiedToClipboard string CommitTagsCopiedToClipboard string CommitHasNoTags string CommitHasNoMessageBody string PatchCopiedToClipboard string CopiedToClipboard string ErrCannotEditDirectory string ErrCannotCopyContentOfDirectory string ErrStageDirWithInlineMergeConflicts string ErrRepositoryMovedOrDeleted string ErrWorktreeMovedOrRemoved string CommandLog string ToggleShowCommandLog string FocusCommandLog string CommandLogHeader string RandomTip string ToggleWhitespaceInDiffView string ToggleWhitespaceInDiffViewTooltip string IgnoreWhitespaceDiffViewSubTitle string IgnoreWhitespaceNotSupportedHere string IncreaseContextInDiffView string IncreaseContextInDiffViewTooltip string DecreaseContextInDiffView string DecreaseContextInDiffViewTooltip string DiffContextSizeChanged string IncreaseRenameSimilarityThreshold string IncreaseRenameSimilarityThresholdTooltip string DecreaseRenameSimilarityThreshold string DecreaseRenameSimilarityThresholdTooltip string RenameSimilarityThresholdChanged string CreatePullRequestOptions string DefaultBranch string SelectBranch string SelectTargetRemote string NoValidRemoteName string CreatePullRequest string SelectConfigFile string NoConfigFileFoundErr string LoadingFileSuggestions string LoadingCommits string MustSpecifyOriginError string GitOutput string GitCommandFailed string AbortTitle string AbortPrompt string OpenLogMenu string OpenLogMenuTooltip string LogMenuTitle string ToggleShowGitGraphAll string ShowGitGraph string SortOrder string SortAlphabetical string SortByDate string SortByRecency string SortBasedOnReflog string SortCommits string CantChangeContextSizeError string OpenCommitInBrowser string ViewBisectOptions string ConfirmRevertCommit string ConfirmRevertCommitRange string RewordInEditorTitle string RewordInEditorPrompt string CheckoutAutostashPrompt string HardResetAutostashPrompt string SoftResetPrompt string UpstreamGone string NukeDescription string DiscardStagedChangesDescription string EmptyOutput string Patch string CustomPatch string CommitsCopied string CommitCopied string ResetPatch string ResetPatchTooltip string ApplyPatch string ApplyPatchTooltip string ApplyPatchInReverse string ApplyPatchInReverseTooltip string RemovePatchFromOriginalCommit string RemovePatchFromOriginalCommitTooltip string MovePatchOutIntoIndex string MovePatchOutIntoIndexTooltip string MovePatchIntoNewCommit string MovePatchIntoNewCommitTooltip string MovePatchToSelectedCommit string MovePatchToSelectedCommitTooltip string CopyPatchToClipboard string NoMatchesFor string MatchesFor string SearchKeybindings string SearchPrefix string FilterPrefix string ExitSearchMode string ExitTextFilterMode string Switch string SwitchToWorktree string SwitchToWorktreeTooltip string AlreadyCheckedOutByWorktree string BranchCheckedOutByWorktree string SomeBranchesCheckedOutByWorktreeError string DetachWorktreeTooltip string Switching string RemoveWorktree string RemoveWorktreeTitle string DetachWorktree string DetachingWorktree string WorktreesTitle string WorktreeTitle string RemoveWorktreePrompt string ForceRemoveWorktreePrompt string RemovingWorktree string AddingWorktree string CantDeleteCurrentWorktree string AlreadyInWorktree string CantDeleteMainWorktree string NoWorktreesThisRepo string MissingWorktree string MainWorktree string NewWorktree string NewWorktreePath string NewWorktreeBase string RemoveWorktreeTooltip string BranchNameCannotBeBlank string NewBranchName string NewBranchNameLeaveBlank string ViewWorktreeOptions string CreateWorktreeFrom string CreateWorktreeFromDetached string LcWorktree string ChangingDirectoryTo string Name string Branch string Path string MarkedBaseCommitStatus string MarkAsBaseCommit string MarkAsBaseCommitTooltip string MarkedCommitMarker string FailedToOpenURL string InvalidLazygitEditURL string NoCopiedCommits string DisabledMenuItemPrefix string QuickStartInteractiveRebase string QuickStartInteractiveRebaseTooltip string CannotQuickStartInteractiveRebase string ToggleRangeSelect string RangeSelectUp string RangeSelectDown string RangeSelectNotSupported string NoItemSelected string SelectedItemIsNotABranch string SelectedItemDoesNotHaveFiles string MultiSelectNotSupportedForSubmodules string OldCherryPickKeyWarning string CommandDoesNotSupportOpeningInEditor string CustomCommands string NoApplicableCommandsInThisContext string SelectCommitsOfCurrentBranch string Actions Actions Bisect Bisect Log Log BreakingChangesTitle string BreakingChangesMessage string BreakingChangesByVersion map[string]string } type Bisect struct { MarkStart string ResetTitle string ResetPrompt string ResetOption string ChooseTerms string OldTermPrompt string NewTermPrompt string BisectMenuTitle string Mark string SkipCurrent string SkipSelected string CompleteTitle string CompletePrompt string CompletePromptIndeterminate string Bisecting string } type Log struct { EditRebase string MoveCommitUp string MoveCommitDown string CherryPickCommits string HandleUndo string HandleMidRebaseCommand string RemoveFile string CopyToClipboard string Remove string CreateFileWithContent string AppendingLineToFile string EditRebaseFromBaseCommit string } type Actions struct { CheckoutCommit string CheckoutBranchAtCommit string CheckoutCommitAsDetachedHead string CheckoutTag string CheckoutBranch string CheckoutBranchOrCommit string ForceCheckoutBranch string DeleteLocalBranch string Merge string SquashMerge string RebaseBranch string RenameBranch string CreateBranch string FastForwardBranch string AutoForwardBranches string CherryPick string CheckoutFile string DiscardOldFileChange string SquashCommitDown string FixupCommit string RewordCommit string DropCommit string EditCommit string AmendCommit string ResetCommitAuthor string SetCommitAuthor string AddCommitCoAuthor string RevertCommit string CreateFixupCommit string SquashAllAboveFixupCommits string MoveCommitUp string MoveCommitDown string CopyCommitMessageToClipboard string CopyCommitMessageBodyToClipboard string CopyCommitSubjectToClipboard string CopyCommitDiffToClipboard string CopyCommitHashToClipboard string CopyCommitURLToClipboard string CopyCommitAuthorToClipboard string CopyCommitAttributeToClipboard string CopyCommitTagsToClipboard string CopyPatchToClipboard string CustomCommand string DiscardAllChangesInDirectory string DiscardUnstagedChangesInDirectory string DiscardAllChangesInFile string DiscardAllUnstagedChangesInFile string StageFile string StageResolvedFiles string UnstageFile string UnstageAllFiles string StageAllFiles string ResolveConflictByKeepingFile string ResolveConflictByDeletingFile string NotEnoughContextToStage string NotEnoughContextToDiscard string NotEnoughContextForCustomPatch string IgnoreExcludeFile string IgnoreFileErr string ExcludeFile string ExcludeGitIgnoreErr string Commit string EditFile string Push string Pull string OpenFile string StashAllChanges string StashAllChangesKeepIndex string StashStagedChanges string StashUnstagedChanges string StashIncludeUntrackedChanges string GitFlowFinish string GitFlowStart string CopyToClipboard string CopySelectedTextToClipboard string RemovePatchFromCommit string MovePatchToSelectedCommit string MovePatchIntoIndex string MovePatchIntoNewCommit string DeleteRemoteBranch string SetBranchUpstream string AddRemote string RemoveRemote string UpdateRemote string ApplyPatch string Stash string RenameStash string RemoveSubmodule string ResetSubmodule string AddSubmodule string UpdateSubmoduleUrl string InitialiseSubmodule string BulkInitialiseSubmodules string BulkUpdateSubmodules string BulkDeinitialiseSubmodules string BulkUpdateRecursiveSubmodules string UpdateSubmodule string CreateLightweightTag string CreateAnnotatedTag string DeleteLocalTag string DeleteRemoteTag string PushTag string NukeWorkingTree string DiscardUnstagedFileChanges string RemoveUntrackedFiles string RemoveStagedFiles string SoftReset string MixedReset string HardReset string Undo string Redo string CopyPullRequestURL string OpenDiffTool string OpenMergeTool string OpenCommitInBrowser string OpenPullRequest string StartBisect string ResetBisect string BisectSkip string BisectMark string RemoveWorktree string AddWorktree string } const englishIntroPopupMessage = ` Thanks for using lazygit! Seriously you rock. Three things to share with you: 1) If you want to learn about lazygit's features, watch this vid: https://youtu.be/CPLdltN7wgE 2) Be sure to read the latest release notes at: https://github.com/jesseduffield/lazygit/releases 3) If you're using git, that makes you a programmer! With your help we can make lazygit better, so consider becoming a contributor and joining the fun at https://github.com/jesseduffield/lazygit You can also sponsor me and tell me what to work on by clicking the donate button at the bottom right. Or even just star the repo to share the love! Press {{confirmationKey}} to get started. ` const englishDeprecatedEditConfigWarning = ` ### Deprecated config warning ### The following config settings are deprecated and will be removed in a future version: {{configs}} Please refer to https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#configuring-file-editing for up-to-date information how to configure your editor. ` const englishNonReloadableConfigWarning = `The following config settings were changed, but the change doesn't take effect immediately. Please quit and restart lazygit for changes to take effect: {{configs}}` // exporting this so we can use it in tests func EnglishTranslationSet() *TranslationSet { return &TranslationSet{ NotEnoughSpace: "Not enough space to render panels", DiffTitle: "Diff", FilesTitle: "Files", BranchesTitle: "Branches", CommitsTitle: "Commits", StashTitle: "Stash", SnakeTitle: "Snake", EasterEgg: "Easter egg", UnstagedChanges: "Unstaged changes", StagedChanges: "Staged changes", MainTitle: "Main", SquashMergeUncommittedTitle: "Squash merge and leave uncommitted", SquashMergeCommittedTitle: "Squash merge and commit", StagingTitle: "Main panel (staging)", MergingTitle: "Main panel (merging)", NormalTitle: "Main panel (normal)", LogTitle: "Log", CommitSummary: "Commit summary", CredentialsUsername: "Username", CredentialsPassword: "Password", CredentialsPassphrase: "Enter passphrase for SSH key", CredentialsPIN: "Enter PIN for SSH key", CredentialsToken: "Enter Token for SSH key", PassUnameWrong: "Password, passphrase and/or username wrong", Commit: "Commit", CommitTooltip: "Commit staged changes.", AmendLastCommit: "Amend last commit", AmendLastCommitTitle: "Amend last commit", SureToAmend: "Are you sure you want to amend last commit? Afterwards, you can change the commit message from the commits panel.", NoCommitToAmend: "There's no commit to amend.", CommitChangesWithEditor: "Commit changes using git editor", FindBaseCommitForFixup: "Find base commit for fixup", FindBaseCommitForFixupTooltip: "Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: ", NoBaseCommitsFound: "No base commits found", MultipleBaseCommitsFoundStaged: "Multiple base commits found. (Try staging fewer changes at once)", MultipleBaseCommitsFoundUnstaged: "Multiple base commits found. (Try staging some of the changes)", BaseCommitIsAlreadyOnMainBranch: "The base commit for this change is already on the main branch", BaseCommitIsNotInCurrentView: "Base commit is not in current view", HunksWithOnlyAddedLinesWarning: "There are ranges of only added lines in the diff; be careful to check that these belong in the found base commit.\n\nProceed?", StatusTitle: "Status", Menu: "Menu", Execute: "Execute", Stage: "Stage", StageTooltip: "Toggle staged for selected file.", ToggleStagedAll: "Stage all", ToggleStagedAllTooltip: "Toggle staged/unstaged for all files in working tree.", ToggleTreeView: "Toggle file tree view", ToggleTreeViewTooltip: "Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.", OpenDiffTool: "Open external diff tool (git difftool)", OpenMergeTool: "Open external merge tool", OpenMergeToolTooltip: "Run `git mergetool`.", Refresh: "Refresh", RefreshTooltip: "Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`.", Push: "Push", PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch.", Pull: "Pull", PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.", Scroll: "Scroll", MergeConflictsTitle: "Merge conflicts", MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.", MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').", MergeConflictDescription_UA: "Conflict: this file is the destination of a move or rename in the incoming changes, but was moved or renamed to a different destination in the current changes. That other destination should also show up as a conflict (marked 'AU'), as well as the file that both were renamed from (marked 'DD').", MergeConflictDescription_DU: "Conflict: this file was deleted in the current changes and modified in the incoming changes.\n\nThe most likely resolution is to delete the file after applying the incoming modifications manually to some other place in the code.", MergeConflictDescription_UD: "Conflict: this file was modified in the current changes and deleted in incoming changes.\n\nThe most likely resolution is to delete the file after applying the current modifications manually to some other place in the code.", MergeConflictIncomingDiff: "Incoming changes:", MergeConflictCurrentDiff: "Current changes:", MergeConflictPressEnterToResolve: "Press %s to resolve.", MergeConflictKeepFile: "Keep file", MergeConflictDeleteFile: "Delete file", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch", TagCheckoutTooltip: "Checkout the selected tag as a detached HEAD.", RemoteBranchCheckoutTooltip: "Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head.", CantPullOrPushSameBranchTwice: "You cannot push or pull a branch while it is already being pushed or pulled", FileFilter: "Filter files by status", CopyToClipboardMenu: "Copy to clipboard", CopyFileName: "File name", CopyRelativeFilePath: "Relative path", CopyAbsoluteFilePath: "Absolute path", CopyFileDiffTooltip: "If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.", CopySelectedDiff: "Diff of selected file", CopyAllFilesDiff: "Diff of all files", CopyFileContent: "Content of selected file", NoContentToCopyError: "Nothing to copy", FileNameCopiedToast: "File name copied to clipboard", FilePathCopiedToast: "File path copied to clipboard", FileDiffCopiedToast: "File diff copied to clipboard", AllFilesDiffCopiedToast: "All files diff copied to clipboard", FileContentCopiedToast: "File content copied to clipboard", FilterStagedFiles: "Show only staged files", FilterUnstagedFiles: "Show only unstaged files", FilterTrackedFiles: "Show only tracked files", FilterUntrackedFiles: "Show only untracked files", NoFilter: "No filter", FilterLabelStagedFiles: "(only staged)", FilterLabelUnstagedFiles: "(only unstaged)", FilterLabelTrackedFiles: "(only tracked)", FilterLabelUntrackedFiles: "(only untracked)", FilterLabelConflictingFiles: "(only conflicting)", NoChangedFiles: "No changed files", SoftReset: "Soft reset", AlreadyCheckedOutBranch: "You have already checked out this branch", SureForceCheckout: "Are you sure you want force checkout? You will lose all local changes", ForceCheckoutBranch: "Force checkout branch", BranchName: "Branch name", NewBranchNameBranchOff: "New branch name (branch is off of '{{.branchName}}')", CantDeleteCheckOutBranch: "You cannot delete the checked out branch!", DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", DeleteBranchesTitle: "Delete selected branches?", DeleteLocalBranch: "Delete local branch", DeleteLocalBranches: "Delete local branches", DeleteRemoteBranchOption: "Delete remote branch", DeleteRemoteBranchPrompt: "Are you sure you want to delete the remote branch '{{.selectedBranchName}}' from '{{.upstream}}'?", DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?", DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?", DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?", ForceDeleteBranchTitle: "Force delete branch", ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?", RebaseBranch: "Rebase", RebaseBranchTooltip: "Rebase the checked-out branch onto the selected branch.", CantRebaseOntoSelf: "You cannot rebase a branch onto itself", CantMergeBranchIntoItself: "You cannot merge a branch into itself", ForceCheckout: "Force checkout", ForceCheckoutTooltip: "Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch.", CheckoutByName: "Checkout by name", CheckoutByNameTooltip: "Checkout by name. In the input box you can enter '-' to switch to the last branch.", RemoteBranchCheckoutTitle: "Checkout {{.branchName}}", RemoteBranchCheckoutPrompt: "How would you like to check out this branch?", CheckoutTypeNewBranch: "New local branch", CheckoutTypeNewBranchTooltip: "Checkout the remote branch as a local branch, tracking the remote branch.", CheckoutTypeDetachedHead: "Detached head", CheckoutTypeDetachedHeadTooltip: "Checkout the remote branch as a detached head, which can be useful if you just want to test the branch but not work on it yourself. You can still create a local branch from it later.", NewBranch: "New branch", NewBranchFromStashTooltip: "Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit.", MoveCommitsToNewBranch: "Move commits to new branch", MoveCommitsToNewBranchTooltip: "Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.\n\nNote that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which).", MoveCommitsToNewBranchFromMainPrompt: "This will take all unpushed commits and move them to a new branch (off of {{.baseBranchName}}). It will then hard-reset the current branch its the upstream branch. Do you want to continue?", MoveCommitsToNewBranchMenuPrompt: "This will take all unpushed commits and move them to a new branch. This new branch can either be created from the main branch ({{.baseBranchName}}) or stacked on top of the current branch. Which of these would you like to do?", MoveCommitsToNewBranchFromBaseItem: "New branch from base branch (%s)", MoveCommitsToNewBranchStackedItem: "New branch stacked on current branch (%s)", CannotMoveCommitsFromDetachedHead: "Cannot move commits from a detached head", CannotMoveCommitsNoUpstream: "Cannot move commits from a branch that has no upstream branch", CannotMoveCommitsBehindUpstream: "Cannot move commits from a branch that is behind its upstream branch", CannotMoveCommitsNoUnpushedCommits: "There are no unpushed commits to move to a new branch", NoBranchesThisRepo: "No branches for this repo", CommitWithoutMessageErr: "You cannot commit without a commit message", Close: "Close", CloseCancel: "Close/Cancel", Confirm: "Confirm", Quit: "Quit", SquashTooltip: "Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it.", NoCommitsThisBranch: "No commits for this branch", UpdateRefHere: "Update branch '{{.ref}}' here", ExecCommandHere: "Execute the following command here:", CannotSquashOrFixupFirstCommit: "There's no commit below to squash into", CannotSquashOrFixupMergeCommit: "Cannot squash or fixup a merge commit", Fixup: "Fixup", SureFixupThisCommit: "Are you sure you want to 'fixup' the selected commit(s) into the commit below?", SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?", Squash: "Squash", SquashMerge: "Squash Merge", PickCommitTooltip: "Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase.", Pick: "Pick", CantPickDisabledReason: "Cannot pick a commit when not mid-rebase", Edit: "Edit", RevertCommit: "Revert commit", Revert: "Revert", RevertCommitTooltip: "Create a revert commit for the selected commit, which applies the selected commit's changes in reverse.", Reword: "Reword", CommitRewordTooltip: "Reword the selected commit's message.", DropCommit: "Drop", DropCommitTooltip: "Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts.", MoveDownCommit: "Move commit down one", MoveUpCommit: "Move commit up one", CannotMoveAnyFurther: "Cannot move any further", CannotMoveMergeCommit: "Cannot move a merge commit", EditCommit: "Edit (start interactive rebase)", EditCommitTooltip: "Edit the selected commit. Use this to start an interactive rebase from the selected commit. When already mid-rebase, this will mark the selected commit for editing, which means that upon continuing the rebase, the rebase will pause at the selected commit to allow you to make changes.", AmendCommitTooltip: "Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase.", Amend: "Amend", ResetAuthor: "Reset author", ResetAuthorTooltip: "Reset the commit's author to the currently configured user. This will also renew the author timestamp", SetAuthor: "Set author", SetAuthorTooltip: "Set the author based on a prompt", AddCoAuthor: "Add co-author", AmendCommitAttribute: "Amend commit attribute", AmendCommitAttributeTooltip: "Set/Reset commit author or set co-author.", SetAuthorPromptTitle: "Set author (must look like 'Name ')", AddCoAuthorPromptTitle: "Add co-author (must look like 'Name ')", AddCoAuthorTooltip: "Add co-author using the Github/Gitlab metadata Co-authored-by.", SureResetCommitAuthor: "The author field of this commit will be updated to match the configured user. This also renews the author timestamp. Continue?", RewordCommitEditor: "Reword with editor", Error: "Error", PickHunk: "Pick hunk", PickAllHunks: "Pick all hunks", Undo: "Undo", UndoReflog: "Undo", RedoReflog: "Redo", UndoTooltip: "The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", RedoTooltip: "The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", UndoMergeResolveTooltip: "Undo last merge conflict resolution.", DiscardAllTooltip: "Discard both staged and unstaged changes in '{{.path}}'.", DiscardUnstagedTooltip: "Discard unstaged changes in '{{.path}}'.", DiscardUnstagedDisabled: "The selected items don't have both staged and unstaged changes.", Pop: "Pop", StashPopTooltip: "Apply the stash entry to your working directory and remove the stash entry.", Drop: "Drop", StashDropTooltip: "Remove the stash entry from the stash list.", Apply: "Apply", StashApplyTooltip: "Apply the stash entry to your working directory.", NoStashEntries: "No stash entries", StashDrop: "Stash drop", SureDropStashEntry: "Are you sure you want to drop the selected stash entry(ies)?", StashPop: "Stash pop", SurePopStashEntry: "Are you sure you want to pop this stash entry?", StashApply: "Stash apply", SureApplyStashEntry: "Are you sure you want to apply this stash entry?", NoTrackedStagedFilesStash: "You have no tracked/staged files to stash", NoFilesToStash: "You have no files to stash", StashChanges: "Stash changes", RenameStash: "Rename stash", RenameStashPrompt: "Rename stash: {{.stashName}}", OpenConfig: "Open config file", EditConfig: "Edit config file", ForcePush: "Force push", ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.", ForcePushDisabled: "Your branch has diverged from the remote branch and you've disabled force pushing", UpdatesRejected: "Updates were rejected. Please fetch and examine the remote changes before pushing again.", UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing", CheckForUpdate: "Check for update", CheckingForUpdates: "Checking for updates...", UpdateAvailableTitle: "Update available!", UpdateAvailable: "Download and install version {{.newVersion}}?", UpdateInProgressWaitingStatus: "Updating", UpdateCompletedTitle: "Update completed!", UpdateCompleted: "Update has been installed successfully. Restart lazygit for it to take effect.", FailedToRetrieveLatestVersionErr: "Failed to retrieve version information", OnLatestVersionErr: "You already have the latest version", MajorVersionErr: "New version ({{.newVersion}}) has non-backwards compatible changes compared to the current version ({{.currentVersion}})", CouldNotFindBinaryErr: "Could not find any binary at {{.url}}", UpdateFailedErr: "Update failed: {{.errMessage}}", ConfirmQuitDuringUpdateTitle: "Currently updating", ConfirmQuitDuringUpdate: "An update is in progress. Are you sure you want to quit?", MergeToolTitle: "Merge tool", MergeToolPrompt: "Are you sure you want to open `git mergetool`?", IntroPopupMessage: englishIntroPopupMessage, DeprecatedEditConfigWarning: englishDeprecatedEditConfigWarning, NonReloadableConfigWarningTitle: "Config changed", NonReloadableConfigWarning: englishNonReloadableConfigWarning, GitconfigParseErr: `Gogit failed to parse your gitconfig file due to the presence of unquoted '\' characters. Removing these should fix the issue.`, EditFile: `Edit file`, EditFileTooltip: "Open file in external editor.", OpenFile: `Open file`, OpenFileTooltip: "Open file in default application.", OpenInEditor: "Open in editor", IgnoreFile: `Add to .gitignore`, ExcludeFile: `Add to .git/info/exclude`, RefreshFiles: `Refresh files`, FocusMainView: "Focus main view", Merge: `Merge`, RegularMerge: "Regular merge", MergeBranchTooltip: "View options for merging the selected item into the current branch (regular merge, squash merge)", ConfirmQuit: `Are you sure you want to quit?`, SwitchRepo: `Switch to a recent repo`, AllBranchesLogGraph: `Show/cycle all branch logs`, UnsupportedGitService: `Unsupported git service`, CreatePullRequest: `Create pull request`, CopyPullRequestURL: `Copy pull request URL to clipboard`, NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`, Fetch: `Fetch`, FetchTooltip: "Fetch changes from remote.", CollapseAll: "Collapse all files", CollapseAllTooltip: "Collapse all directories in the files tree", ExpandAll: "Expand all files", ExpandAllTooltip: "Expand all directories in the file tree", DisabledInFlatView: "Not available in flat view", FileEnter: `Stage lines / Collapse directory`, FileEnterTooltip: "If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it.", FileStagingRequirements: `Can only stage individual lines for tracked files`, StageSelectionTooltip: `Toggle selection staged / unstaged.`, DiscardSelection: `Discard`, DiscardSelectionTooltip: "When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change.", ToggleRangeSelect: "Toggle range select", ToggleSelectHunk: "Select hunk", ToggleSelectHunkTooltip: "Toggle hunk selection mode.", ToggleSelectionForPatch: `Toggle lines in patch`, EditHunk: `Edit hunk`, EditHunkTooltip: "Edit selected hunk in external editor.", ToggleStagingView: "Switch view", ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).", ReturnToFilesPanel: `Return to files panel`, FastForward: `Fast-forward`, FastForwardTooltip: "Fast-forward selected branch from its upstream.", FastForwarding: "Fast-forwarding", FoundConflictsTitle: "Conflicts!", ViewConflictsMenuItem: "View conflicts", AbortMenuItem: "Abort the %s", ViewMergeRebaseOptions: "View merge/rebase options", ViewMergeRebaseOptionsTooltip: "View options to abort/continue/skip the current merge/rebase.", ViewMergeOptions: "View merge options", ViewRebaseOptions: "View rebase options", ViewCherryPickOptions: "View cherry-pick options", ViewRevertOptions: "View revert options", NotMergingOrRebasing: "You are currently neither rebasing nor merging", AlreadyRebasing: "Can't perform this action during a rebase", RecentRepos: "Recent repositories", MergeOptionsTitle: "Merge options", RebaseOptionsTitle: "Rebase options", CherryPickOptionsTitle: "Cherry-pick options", RevertOptionsTitle: "Revert options", CommitSummaryTitle: "Commit summary", CommitDescriptionTitle: "Commit description", CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus, {{.commitMenuKeybinding}} to open menu", CommitDescriptionFooter: "Press {{.confirmInEditorKeybinding}} to commit", CommitHooksDisabledSubTitle: "(hooks disabled)", LocalBranchesTitle: "Local branches", SearchTitle: "Search", TagsTitle: "Tags", MenuTitle: "Menu", CommitMenuTitle: "Commit Menu", RemotesTitle: "Remotes", RemoteBranchesTitle: "Remote branches", PatchBuildingTitle: "Main panel (patch building)", InformationTitle: "Information", SecondaryTitle: "Secondary", ReflogCommitsTitle: "Reflog", GlobalTitle: "Global keybindings", ConflictsResolved: "All merge conflicts resolved. Continue the %s?", Continue: "Continue", UnstagedFilesAfterConflictsResolved: "Files have been modified since conflicts were resolved. Auto-stage them and continue?", Keybindings: "Keybindings", KeybindingsMenuSectionLocal: "Local", KeybindingsMenuSectionGlobal: "Global", KeybindingsMenuSectionNavigation: "Navigation", RebasingTitle: "Rebase '{{.checkedOutBranch}}'", RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base", SimpleRebase: "Simple rebase onto '{{.ref}}'", InteractiveRebase: "Interactive rebase onto '{{.ref}}'", RebaseOntoBaseBranch: "Rebase onto base branch ({{.baseBranch}})", InteractiveRebaseTooltip: "Begin an interactive rebase with a break at the start, so you can update the TODO commits before continuing.", RebaseOntoBaseBranchTooltip: "Rebase the checked out branch onto its base branch (i.e. the closest main branch).", MustSelectTodoCommits: "When rebasing, this action only works on a selection of TODO commits.", SquashMergeUncommitted: "Squash merge '{{.selectedBranch}}' into the working tree.", SquashMergeCommitted: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", RegularMergeTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}'.", FwdNoUpstream: "Cannot fast-forward a branch with no upstream", FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream", ErrorOccurred: "An error occurred! Please create an issue at", NoRoom: "Not enough room", ConflictLabel: "CONFLICT", PendingRebaseTodosSectionHeader: "Pending rebase todos", PendingCherryPicksSectionHeader: "Pending cherry-picks", PendingRevertsSectionHeader: "Pending reverts", CommitsSectionHeader: "Commits", YouDied: "YOU DIED!", RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", NotAllowedMidCherryPickOrRevert: "This action is not allowed while cherry-picking or reverting", DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item", CherryPickCopy: "Copy (cherry-pick)", CherryPickCopyTooltip: "Mark commit as copied. Then, within the local commits view, you can press `{{.paste}}` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `{{.escape}}` to cancel the selection.", CherryPickCopyRangeTooltip: "Mark commits as copied from the last copied commit to the selected commit.", PasteCommits: "Paste (cherry-pick)", SureCherryPick: "Are you sure you want to cherry-pick the {{.numCommits}} copied commit(s) onto this branch?", CherryPick: "Cherry-pick", CannotCherryPickNonCommit: "Cannot cherry-pick this kind of todo item", CannotCherryPickMergeCommit: "Cherry-picking merge commits is not supported", Donate: "Donate", AskQuestion: "Ask Question", PrevLine: "Select previous line", NextLine: "Select next line", PrevHunk: "Go to previous hunk", NextHunk: "Go to next hunk", PrevConflict: "Previous conflict", NextConflict: "Next conflict", SelectPrevHunk: "Previous hunk", SelectNextHunk: "Next hunk", ScrollDown: "Scroll down", ScrollUp: "Scroll up", ScrollUpMainWindow: "Scroll up main window", ScrollDownMainWindow: "Scroll down main window", AmendCommitTitle: "Amend commit", AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?", AmendCommitWithConflictsMenuPrompt: "WARNING: you are about to amend the last finished commit with your resolved conflicts. This is very unlikely to be what you want at this point. More likely, you simply want to continue the rebase instead.\n\nDo you still want to amend the previous commit?", AmendCommitWithConflictsContinue: "No, continue rebase", AmendCommitWithConflictsAmend: "Yes, amend previous commit", DropCommitTitle: "Drop commit", DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?", DropMergeCommitPrompt: "Are you sure you want to drop the selected merge commit? Note that it will also drop all the commits that were merged in by it.", DropUpdateRefPrompt: "Are you sure you want to delete the selected update-ref todo(s)? This is irreversible except by aborting the rebase.", PullingStatus: "Pulling", PushingStatus: "Pushing", FetchingStatus: "Fetching", SquashingStatus: "Squashing", FixingStatus: "Fixing up", DeletingStatus: "Deleting", DroppingStatus: "Dropping", MovingStatus: "Moving", RebasingStatus: "Rebasing", MergingStatus: "Merging", LowercaseRebasingStatus: "rebasing", // lowercase because it shows up in parentheses LowercaseMergingStatus: "merging", // lowercase because it shows up in parentheses LowercaseCherryPickingStatus: "cherry-picking", // lowercase because it shows up in parentheses LowercaseRevertingStatus: "reverting", // lowercase because it shows up in parentheses AmendingStatus: "Amending", CherryPickingStatus: "Cherry-picking", UndoingStatus: "Undoing", RedoingStatus: "Redoing", CheckingOutStatus: "Checking out", CommittingStatus: "Committing", RewordingStatus: "Rewording", RevertingStatus: "Reverting", CreatingFixupCommitStatus: "Creating fixup commit", MovingCommitsToNewBranchStatus: "Moving commits to new branch", CommitFiles: "Commit files", SubCommitsDynamicTitle: "Commits (%s)", CommitFilesDynamicTitle: "Diff files (%s)", RemoteBranchesDynamicTitle: "Remote branches (%s)", ViewItemFiles: "View files", ViewItemFilesTooltip: "View the files modified by the selected item.", CommitFilesTitle: "Commit files", CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.", CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits", Remove: "Remove", DiscardOldFileChangeTooltip: "Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file.", DiscardFileChangesTitle: "Discard file changes", DiscardFileChangesPrompt: "Are you sure you want to remove changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\nNote: This will also reset any active custom patches.", DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", CreateRepo: "Not in a git repository. Create a new git repository? (y/n): ", BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ", InitialBranch: "Branch name? (leave empty for git's default): ", NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", AutoStashTitle: "Autostash?", AutoStashPrompt: "You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)", StashPrefix: "Auto-stashing changes for ", Discard: "Discard", DiscardFileChangesTooltip: "View options for discarding changes to the selected file.", DiscardChangesTitle: "Discard changes", Cancel: "Cancel", DiscardAllChanges: "Discard all changes", DiscardUnstagedChanges: "Discard unstaged changes", DiscardAllChangesToAllFiles: "Nuke working tree", DiscardAnyUnstagedChanges: "Discard unstaged changes", DiscardUntrackedFiles: "Discard untracked files", DiscardStagedChanges: "Discard staged changes", HardReset: "Hard reset", BranchDeleteTooltip: "View delete options for local/remote branch.", TagDeleteTooltip: "View delete options for local/remote tag.", Delete: "Delete", Reset: "Reset", ResetTooltip: "View reset options (soft/mixed/hard) for resetting onto selected item.", ResetSoftTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as staged changes.", ResetMixedTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as unstaged changes.", ResetHardTooltip: "Reset HEAD to the chosen commit, and discard all changes between the current and chosen commit, as well as all current modifications in the working tree.", ViewResetOptions: `Reset`, FileResetOptionsTooltip: "View reset options for working tree (e.g. nuking the working tree).", FixupTooltip: "Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded.", CreateFixupCommit: "Create fixup commit", CreateFixupCommitTooltip: "Create 'fixup!' commit for the selected commit. Later on, you can press `{{.squashAbove}}` on this same commit to apply all above fixup commits.", CreateAmendCommit: `Create "amend!" commit`, FixupMenu_Fixup: "fixup! commit", FixupMenu_FixupTooltip: "Lets you fixup another commit and keep the original commit's message.", FixupMenu_AmendWithChanges: "amend! commit with changes", FixupMenu_AmendWithChangesTooltip: "Lets you fixup another commit and also change its commit message.", FixupMenu_AmendWithoutChanges: "amend! commit without changes (pure reword)", FixupMenu_AmendWithoutChangesTooltip: "Lets you change the commit message of another commit without changing its content.", SquashAboveCommits: "Apply fixup commits", SquashAboveCommitsTooltip: `Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash).`, SquashCommitsAboveSelectedTooltip: `Squash all 'fixup!' commits above the selected commit (autosquash).`, SquashCommitsInCurrentBranchTooltip: `Squash all 'fixup!' commits in the current branch (autosquash).`, SquashCommitsInCurrentBranch: "In current branch", SquashCommitsAboveSelectedCommit: "Above the selected commit", CannotSquashCommitsInCurrentBranch: "Cannot squash commits in current branch: the HEAD commit is a merge commit or is present on the main branch.", ExecuteShellCommand: "Execute shell command", ExecuteShellCommandTooltip: "Bring up a prompt where you can enter a shell command to execute.", ShellCommand: "Shell command:", CommitChangesWithoutHook: "Commit changes without pre-commit hook", ResetTo: `Reset to`, PressEnterToReturn: "Press enter to return to lazygit", ViewStashOptions: "View stash options", ViewStashOptionsTooltip: "View stash options (e.g. stash all, stash staged, stash unstaged).", Stash: "Stash", StashTooltip: "Stash all changes. For other variations of stashing, use the view stash options keybinding.", StashAllChanges: "Stash all changes", StashStagedChanges: "Stash staged changes", StashAllChangesKeepIndex: "Stash all changes and keep index", StashUnstagedChanges: "Stash unstaged changes", StashIncludeUntrackedChanges: "Stash all changes including untracked files", StashOptions: "Stash options", NotARepository: "Error: must be run inside a git repository", WorkingDirectoryDoesNotExist: "Error: the current working directory does not exist", Jump: "Jump to panel", ScrollLeftRight: "Scroll left/right", ScrollLeft: "Scroll left", ScrollRight: "Scroll right", DiscardPatch: "Discard patch", DiscardPatchConfirm: "You can only build a patch from one commit/stash-entry at a time. Discard current patch?", CantPatchWhileRebasingError: "You cannot build a patch or run patch commands while in a merging or rebasing state", ToggleAddToPatch: "Toggle file included in patch", ToggleAddToPatchTooltip: "Toggle whether the file is included in the custom patch. See {{.doc}}.", ToggleAllInPatch: "Toggle all files", ToggleAllInPatchTooltip: "Add/remove all commit's files to custom patch. See {{.doc}}.", UpdatingPatch: "Updating patch", ViewPatchOptions: "View custom patch options", PatchOptionsTitle: "Patch options", NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines", EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.", EnterCommitFile: "Enter file / Toggle directory collapsed", EnterCommitFileTooltip: "If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory.", ExitCustomPatchBuilder: `Exit custom patch builder`, ExitFocusedMainView: "Exit back to side panel", EnterUpstream: `Enter upstream as ' '`, InvalidUpstream: "Invalid upstream. Must be in the format ' '", ReturnToRemotesList: `Return to remotes list`, NewRemote: `New remote`, NewRemoteName: `New remote name:`, NewRemoteUrl: `New remote url:`, ViewBranches: "View branches", EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, RemoveRemote: `Remove remote`, RemoveRemoteTooltip: `Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected.`, RemoveRemotePrompt: "Are you sure you want to remove remote?", DeleteRemoteBranch: "Delete remote branch", DeleteRemoteBranches: "Delete remote branches", DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.", DeleteLocalAndRemoteBranch: "Delete local and remote branch", DeleteLocalAndRemoteBranches: "Delete local and remote branches", SetAsUpstream: "Set as upstream", SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", SetUpstream: "Set upstream of selected branch", UnsetUpstream: "Unset upstream of selected branch", ViewDivergenceFromUpstream: "View divergence from upstream", ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", CouldNotDetermineBaseBranch: "Couldn't determine base branch", DivergenceSectionHeaderLocal: "Local", DivergenceSectionHeaderRemote: "Remote", ViewUpstreamResetOptions: "Reset checked-out branch onto {{.upstream}}", ViewUpstreamResetOptionsTooltip: "View options for resetting the checked-out branch onto {{upstream}}. Note: this will not reset the selected branch onto the upstream, it will reset the checked-out branch onto the upstream.", ViewUpstreamRebaseOptions: "Rebase checked-out branch onto {{.upstream}}", ViewUpstreamRebaseOptionsTooltip: "View options for rebasing the checked-out branch onto {{upstream}}. Note: this will not rebase the selected branch onto the upstream, it will rebase the checked-out branch onto the upstream.", UpstreamGenericName: "upstream of selected branch", SetUpstreamTitle: "Set upstream branch", SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'?", EditRemoteTooltip: "Edit the selected remote's name or URL.", TagCommit: "Tag commit", TagCommitTooltip: "Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description.", TagMenuTitle: "Create tag", TagNameTitle: "Tag name", TagMessageTitle: "Tag description", AnnotatedTag: "Annotated tag", LightweightTag: "Lightweight tag", DeleteTagTitle: "Delete tag '{{.tagName}}'?", DeleteLocalTag: "Delete local tag", DeleteRemoteTag: "Delete remote tag", DeleteLocalAndRemoteTag: "Delete local and remote tag", RemoteTagDeletedMessage: "Remote tag deleted", SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':", DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?", DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?", PushTagTitle: "Remote to push tag '{{.tagName}}' to:", // Using 'push tag' rather than just 'push' to disambiguate from a global push PushTag: "Push tag", PushTagTooltip: "Push the selected tag to a remote. You'll be prompted to select a remote.", NewTag: "New tag", NewTagTooltip: "Create new tag from current commit. You'll be prompted to enter a tag name and optional description.", CreatingTag: "Creating tag", ForceTag: "Force Tag", ForceTagPrompt: "The tag '{{.tagName}}' exists already. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to overwrite.", FetchRemoteTooltip: "Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches.", FetchingRemoteStatus: "Fetching remote", CheckoutCommit: "Checkout commit", CheckoutCommitTooltip: "Checkout the selected commit as a detached HEAD.", NoBranchesFoundAtCommitTooltip: "No branches found at selected commit.", SureCheckoutThisCommit: "Are you sure you want to checkout this commit?", GitFlowOptions: "Show git-flow options", NotAGitFlowBranch: "This does not seem to be a git flow branch", NewGitFlowBranchPrompt: "New {{.branchType}} name:", IgnoreTracked: "Ignore tracked file", IgnoreTrackedPrompt: "Are you sure you want to ignore a tracked file?", ExcludeTracked: "Exclude tracked file", ExcludeTrackedPrompt: "Are you sure you want to exclude a tracked file?", ViewResetToUpstreamOptions: "View upstream reset options", NextScreenMode: "Next screen mode (normal/half/fullscreen)", PrevScreenMode: "Prev screen mode", StartSearch: "Search the current view by text", StartFilter: "Filter the current view by text", Panel: "Panel", KeybindingsLegend: "Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b", RenameBranch: "Rename branch", BranchUpstreamOptionsTitle: "Upstream options", ViewBranchUpstreamOptions: "View upstream options", ViewBranchUpstreamOptionsTooltip: "View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream.", UpstreamNotSetError: "The selected branch has no upstream (or the upstream is not stored locally)", UpstreamsNotSetError: "Some of the selected branches have no upstream (or the upstream is not stored locally)", Upstream: "Upstream", UpstreamTooltip: "View upstream options for selected branch e.g. setting/unsetting the upstream and resetting to the upstream.", NewBranchNamePrompt: "Enter new branch name for branch", RenameBranchWarning: "This branch is tracking a remote. This action will only rename the local branch name, not the name of the remote branch. Continue?", OpenKeybindingsMenu: "Open keybindings menu", ResetCherryPick: "Reset copied (cherry-picked) commits selection", NextTab: "Next tab", PrevTab: "Previous tab", CantUndoWhileRebasing: "Can't undo while rebasing", CantRedoWhileRebasing: "Can't redo while rebasing", MustStashWarning: "Pulling a patch out into the index requires stashing and unstashing your changes. If something goes wrong, you'll be able to access your files from the stash. Continue?", MustStashTitle: "Must stash", ConfirmationTitle: "Confirmation panel", PrevPage: "Previous page", NextPage: "Next page", GotoTop: "Scroll to top", GotoBottom: "Scroll to bottom", FilteringBy: "Filtering by", ResetInParentheses: "(Reset)", OpenFilteringMenu: "View filter options", OpenFilteringMenuTooltip: "View options for filtering the commit log, so that only commits matching the filter are shown.", FilterBy: "Filter by", ExitFilterMode: "Stop filtering", FilterPathOption: "Enter path to filter by", FilterAuthorOption: "Enter author to filter by", EnterFileName: "Enter path:", EnterAuthor: "Enter author:", FilteringMenuTitle: "Filtering", WillCancelExistingFilterTooltip: "Note: this will cancel the existing filter", MustExitFilterModeTitle: "Command not available", MustExitFilterModePrompt: "Command not available in filter-by-path mode. Exit filter-by-path mode?", Diff: "Diff", EnterRefToDiff: "Enter ref to diff", EnterRefName: "Enter ref:", ExitDiffMode: "Exit diff mode", DiffingMenuTitle: "Diffing", SwapDiff: "Reverse diff direction", ViewDiffingOptions: "View diffing options", ViewDiffingOptionsTooltip: "View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction.", // the actual view is the extras view which I intend to give more tabs in future but for now we'll only mention the command log part OpenCommandLogMenu: "View command log options", OpenCommandLogMenuTooltip: "View options for the command log e.g. show/hide the command log and focus the command log.", ShowingGitDiff: "Showing output for:", ShowingDiffForRange: "Showing diff for range", CommitDiff: "Commit diff", CopyCommitHashToClipboard: "Copy commit hash to clipboard", CommitHash: "Commit hash", CommitURL: "Commit URL", CopyCommitMessageToClipboard: "Copy commit message to clipboard", PasteCommitMessageFromClipboard: "Paste commit message from clipboard", SurePasteCommitMessage: "Pasting will overwrite the current commit message, continue?", CommitMessage: "Commit message (subject and body)", CommitMessageBody: "Commit message body", CommitSubject: "Commit subject", CommitAuthor: "Commit author", CommitTags: "Commit tags", CopyCommitAttributeToClipboard: "Copy commit attribute to clipboard", CopyCommitAttributeToClipboardTooltip: "Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author).", CopyBranchNameToClipboard: "Copy branch name to clipboard", CopyTagToClipboard: "Copy tag to clipboard", CopyPathToClipboard: "Copy path to clipboard", CopySelectedTextToClipboard: "Copy selected text to clipboard", CommitPrefixPatternError: "Error in commitPrefix pattern", NoFilesStagedTitle: "No files staged", NoFilesStagedPrompt: "You have not staged any files. Commit all files?", BranchNotFoundTitle: "Branch not found", BranchNotFoundPrompt: "Branch not found. Create a new branch named", BranchUnknown: "Branch unknown", DiscardChangeTitle: "Discard change", DiscardChangePrompt: "Are you sure you want to discard this change (git reset)? It is irreversible.\nTo disable this dialogue set the config key of 'gui.skipDiscardChangeWarning' to true", CreateNewBranchFromCommit: "Create new branch off of commit", BuildingPatch: "Building patch", ViewCommits: "View commits", MinGitVersionError: "Git version must be at least 2.22 (i.e. from 2019 onwards). Please upgrade your git version. Alternatively raise an issue at https://github.com/jesseduffield/lazygit/issues for lazygit to be more backwards compatible.", RunningCustomCommandStatus: "Running custom command", SubmoduleStashAndReset: "Stash uncommitted submodule changes and update", AndResetSubmodules: "And reset submodules", Enter: "Enter", EnterSubmoduleTooltip: "Enter submodule. After entering the submodule, you can press `{{.escape}}` to escape back to the parent repo.", CopySubmoduleNameToClipboard: "Copy submodule name to clipboard", RemoveSubmodule: "Remove submodule", RemoveSubmodulePrompt: "Are you sure you want to remove submodule '%s' and its corresponding directory? This is irreversible.", RemoveSubmoduleTooltip: "Remove the selected submodule and its corresponding directory.", ResettingSubmoduleStatus: "Resetting submodule", NewSubmoduleName: "New submodule name:", NewSubmoduleUrl: "New submodule URL:", NewSubmodulePath: "New submodule path:", NewSubmodule: "New submodule", AddingSubmoduleStatus: "Adding submodule", UpdateSubmoduleUrl: "Update URL for submodule '%s'", UpdatingSubmoduleUrlStatus: "Updating URL", EditSubmoduleUrl: "Update submodule URL", InitializingSubmoduleStatus: "Initializing submodule", InitSubmoduleTooltip: "Initialize the selected submodule to prepare for fetching. You probably want to follow this up by invoking the 'update' action to fetch the submodule.", Update: "Update", Initialize: "Initialize", SubmoduleUpdateTooltip: "Update selected submodule.", UpdatingSubmoduleStatus: "Updating submodule", BulkInitSubmodules: "Bulk init submodules", BulkUpdateSubmodules: "Bulk update submodules", BulkDeinitSubmodules: "Bulk deinit submodules", BulkUpdateRecursiveSubmodules: "Bulk init and update submodules recursively", ViewBulkSubmoduleOptions: "View bulk submodule options", BulkSubmoduleOptions: "Bulk submodule options", RunningCommand: "Running command", SubCommitsTitle: "Sub-commits", SubmodulesTitle: "Submodules", NavigationTitle: "List panel navigation", SuggestionsCheatsheetTitle: "Suggestions", SuggestionsTitle: "Suggestions (press %s to focus)", SuggestionsSubtitle: "(press %s to delete, %s to edit)", ExtrasTitle: "Command log", PushingTagStatus: "Pushing tag", PullRequestURLCopiedToClipboard: "Pull request URL copied to clipboard", CommitDiffCopiedToClipboard: "Commit diff copied to clipboard", CommitURLCopiedToClipboard: "Commit URL copied to clipboard", CommitMessageCopiedToClipboard: "Commit message copied to clipboard", CommitMessageBodyCopiedToClipboard: "Commit message body copied to clipboard", CommitSubjectCopiedToClipboard: "Commit subject copied to clipboard", CommitAuthorCopiedToClipboard: "Commit author copied to clipboard", CommitTagsCopiedToClipboard: "Commit tags copied to clipboard", CommitHasNoTags: "Commit has no tags", CommitHasNoMessageBody: "Commit has no message body", PatchCopiedToClipboard: "Patch copied to clipboard", CopiedToClipboard: "copied to clipboard", ErrCannotEditDirectory: "Cannot edit directories: you can only edit individual files", ErrCannotCopyContentOfDirectory: "Cannot copy content of directories: you can only copy content of individual files", ErrStageDirWithInlineMergeConflicts: "Cannot stage/unstage directory containing files with inline merge conflicts. Please fix up the merge conflicts first", ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯", CommandLog: "Command log", ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯", ToggleShowCommandLog: "Toggle show/hide command log", FocusCommandLog: "Focus command log", CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n", RandomTip: "Random tip", ToggleWhitespaceInDiffView: "Toggle whitespace", ToggleWhitespaceInDiffViewTooltip: "Toggle whether or not whitespace changes are shown in the diff view.", IgnoreWhitespaceDiffViewSubTitle: "(ignoring whitespace)", IgnoreWhitespaceNotSupportedHere: "Ignoring whitespace is not supported in this view", IncreaseContextInDiffView: "Increase diff context size", IncreaseContextInDiffViewTooltip: "Increase the amount of the context shown around changes in the diff view.", DecreaseContextInDiffView: "Decrease diff context size", DecreaseContextInDiffViewTooltip: "Decrease the amount of the context shown around changes in the diff view.", DiffContextSizeChanged: "Changed diff context size to %d", IncreaseRenameSimilarityThresholdTooltip: "Increase the similarity threshold for a deletion and addition pair to be treated as a rename.", IncreaseRenameSimilarityThreshold: "Increase rename similarity threshold", DecreaseRenameSimilarityThresholdTooltip: "Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.", DecreaseRenameSimilarityThreshold: "Decrease rename similarity threshold", RenameSimilarityThresholdChanged: "Changed rename similarity threshold to %d%%", CreatePullRequestOptions: "View create pull request options", DefaultBranch: "Default branch", SelectBranch: "Select branch", SelectTargetRemote: "Select target remote", NoValidRemoteName: "A remote named '%s' does not exist", SelectConfigFile: "Select config file", NoConfigFileFoundErr: "No config file found", LoadingFileSuggestions: "Loading file suggestions", LoadingCommits: "Loading commits", MustSpecifyOriginError: "Must specify a remote if specifying a branch", GitOutput: "Git output:", GitCommandFailed: "Git command failed. Check command log for details (open with %s)", AbortTitle: "Abort %s", AbortPrompt: "Are you sure you want to abort the current %s?", OpenLogMenu: "View log options", OpenLogMenuTooltip: "View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph.", LogMenuTitle: "Commit Log Options", ToggleShowGitGraphAll: "Toggle show whole git graph (pass the `--all` flag to `git log`)", ShowGitGraph: "Show git graph", SortOrder: "Sort order", SortAlphabetical: "Alphabetical", SortByDate: "Date", SortByRecency: "Recency", SortBasedOnReflog: "(based on reflog)", SortCommits: "Commit sort order", CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!", OpenCommitInBrowser: "Open commit in browser", ViewBisectOptions: "View bisect options", ConfirmRevertCommit: "Are you sure you want to revert {{.selectedCommit}}?", ConfirmRevertCommitRange: "Are you sure you want to revert the selected commits?", RewordInEditorTitle: "Reword in editor", RewordInEditorPrompt: "Are you sure you want to reword this commit in your editor?", HardResetAutostashPrompt: "Are you sure you want to hard reset to '%s'? An auto-stash will be performed if necessary.", SoftResetPrompt: "Are you sure you want to soft reset to '%s'?", CheckoutAutostashPrompt: "Are you sure you want to checkout '%s'? An auto-stash will be performed if necessary.", UpstreamGone: "(upstream gone)", NukeDescription: "If you want to make all the changes in the worktree go away, this is the way to do it. If there are dirty submodule changes this will stash those changes in the submodule(s).", DiscardStagedChangesDescription: "This will create a new stash entry containing only staged files and then drop it, so that the working tree is left with only unstaged changes", EmptyOutput: "", Patch: "Patch", CustomPatch: "Custom patch", CommitsCopied: "commits copied", // lowercase because it's used in a sentence CommitCopied: "commit copied", // lowercase because it's used in a sentence ResetPatch: "Reset patch", ResetPatchTooltip: "Clear the current patch.", ApplyPatch: "Apply patch", ApplyPatchTooltip: "Apply the current patch to the working tree.", ApplyPatchInReverse: "Apply patch in reverse", ApplyPatchInReverseTooltip: "Apply the current patch in reverse to the working tree.", RemovePatchFromOriginalCommit: "Remove patch from original commit (%s)", RemovePatchFromOriginalCommitTooltip: "Remove the current patch from its commit. This is achieved by starting an interactive rebase at the commit, applying the patch in reverse, and then continuing the rebase. If later commits depend on the patch, you may need to resolve conflicts.", MovePatchOutIntoIndex: "Move patch out into index", MovePatchOutIntoIndexTooltip: "Move the patch out of its commit and into the index. This is achieved by starting an interactive rebase at the commit, applying the patch in reverse, continuing the rebase to completion, and then applying the patch to the index. If later commits depend on the patch, you may need to resolve conflicts.", MovePatchIntoNewCommit: "Move patch into new commit", MovePatchIntoNewCommitTooltip: "Move the patch out of its commit and into a new commit sitting on top of the original commit. This is achieved by starting an interactive rebase at the original commit, applying the patch in reverse, then applying the patch to the index and committing it as a new commit, before continuing the rebase to completion. If later commits depend on the patch, you may need to resolve conflicts.", MovePatchToSelectedCommit: "Move patch to selected commit (%s)", MovePatchToSelectedCommitTooltip: "Move the patch out of its original commit and into the selected commit. This is achieved by starting an interactive rebase at the original commit, applying the patch in reverse, then continuing the rebase up to the selected commit, before applying the patch forward and amending the selected commit. The rebase is then continued to completion. If commits between the source and destination commit depend on the patch, you may need to resolve conflicts.", CopyPatchToClipboard: "Copy patch to clipboard", NoMatchesFor: "No matches for '%s' %s", ExitSearchMode: "%s: Exit search mode", ExitTextFilterMode: "%s: Exit filter mode", MatchesFor: "matches for '%s' (%d of %d) %s", // lowercase because it's after other text SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode", SearchPrefix: "Search: ", FilterPrefix: "Filter: ", WorktreesTitle: "Worktrees", WorktreeTitle: "Worktree", Switch: "Switch", SwitchToWorktree: "Switch to worktree", SwitchToWorktreeTooltip: "Switch to the selected worktree.", AlreadyCheckedOutByWorktree: "This branch is checked out by worktree {{.worktreeName}}. Do you want to switch to that worktree?", BranchCheckedOutByWorktree: "Branch {{.branchName}} is checked out by worktree {{.worktreeName}}", SomeBranchesCheckedOutByWorktreeError: "Some of the selected branches are checked out by other worktrees. Select them one by one to delete them.", DetachWorktreeTooltip: "This will run `git checkout --detach` on the worktree so that it stops hogging the branch, but the worktree's working tree will be left alone.", Switching: "Switching", RemoveWorktree: "Remove worktree", RemoveWorktreeTitle: "Remove worktree", RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?", ForceRemoveWorktreePrompt: "'{{.worktreeName}}' contains modified or untracked files (to be honest, it could contain both). Are you sure you want to remove it?", RemovingWorktree: "Deleting worktree", DetachWorktree: "Detach worktree", DetachingWorktree: "Detaching worktree", AddingWorktree: "Adding worktree", CantDeleteCurrentWorktree: "You cannot remove the current worktree!", AlreadyInWorktree: "You are already in the selected worktree", CantDeleteMainWorktree: "You cannot remove the main worktree!", NoWorktreesThisRepo: "No worktrees", MissingWorktree: "(missing)", MainWorktree: "(main)", NewWorktree: "New worktree", NewWorktreePath: "New worktree path", NewWorktreeBase: "New worktree base ref", RemoveWorktreeTooltip: "Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory.", BranchNameCannotBeBlank: "Branch name cannot be blank", NewBranchName: "New branch name", NewBranchNameLeaveBlank: "New branch name (leave blank to checkout {{.default}})", ViewWorktreeOptions: "View worktree options", CreateWorktreeFrom: "Create worktree from {{.ref}}", CreateWorktreeFromDetached: "Create worktree from {{.ref}} (detached)", LcWorktree: "worktree", ChangingDirectoryTo: "Changing directory to {{.path}}", Name: "Name", Branch: "Branch", Path: "Path", MarkedBaseCommitStatus: "Marked a base commit for rebase", MarkAsBaseCommit: "Mark as base commit for rebase", MarkAsBaseCommitTooltip: "Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command.", MarkedCommitMarker: "↑↑↑ Will rebase from here ↑↑↑", FailedToOpenURL: "Failed to open URL %s\n\nError: %v", InvalidLazygitEditURL: "Invalid lazygit-edit URL format: %s", DisabledMenuItemPrefix: "Disabled: ", NoCopiedCommits: "No copied commits", QuickStartInteractiveRebase: "Start interactive rebase", QuickStartInteractiveRebaseTooltip: "Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.\nIf you would instead like to start an interactive rebase from the selected commit, press `{{.editKey}}`.", CannotQuickStartInteractiveRebase: "Cannot start interactive rebase: the HEAD commit is a merge commit or is present on the main branch, so there is no appropriate base commit to start the rebase from. You can start an interactive rebase from a specific commit by selecting the commit and pressing `{{.editKey}}`.", RangeSelectUp: "Range select up", RangeSelectDown: "Range select down", RangeSelectNotSupported: "Action does not support range selection, please select a single item", NoItemSelected: "No item selected", SelectedItemIsNotABranch: "Selected item is not a branch", SelectedItemDoesNotHaveFiles: "Selected item does not have files to view", MultiSelectNotSupportedForSubmodules: "Multiselection not supported for submodules", OldCherryPickKeyWarning: "The 'c' key is no longer the default key for copying commits to cherry pick. Please use `{{.copy}}` instead (and `{{.paste}}` to paste). The reason for this change is that the 'v' key for selecting a range of lines when staging is now also used for selecting a range of lines in any list view, meaning that we needed to find a new key for pasting commits, and if we're going to now use `{{.paste}}` for pasting commits, we may as well use `{{.copy}}` for copying them. If you want to configure the keybindings to get the old behaviour, set the following in your config:\n\nkeybinding:\n universal:\n toggleRangeSelect: \n commits:\n cherryPickCopy: 'c'\n pasteCommits: 'v'", CommandDoesNotSupportOpeningInEditor: "This command doesn't support switching to the editor", CustomCommands: "Custom commands", NoApplicableCommandsInThisContext: "(No applicable commands in this context)", SelectCommitsOfCurrentBranch: "Select commits of current branch", Actions: Actions{ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm) CheckoutCommit: "Checkout commit", CheckoutBranchAtCommit: "Checkout branch '%s'", CheckoutCommitAsDetachedHead: "Checkout commit %s as detached head", CheckoutTag: "Checkout tag", CheckoutBranch: "Checkout branch", ForceCheckoutBranch: "Force checkout branch", CheckoutBranchOrCommit: "Checkout branch or commit", DeleteLocalBranch: "Delete local branch", Merge: "Merge", SquashMerge: "Squash merge", RebaseBranch: "Rebase branch", RenameBranch: "Rename branch", CreateBranch: "Create branch", CherryPick: "(Cherry-pick) paste commits", CheckoutFile: "Checkout file", DiscardOldFileChange: "Discard old file change", SquashCommitDown: "Squash commit down", FixupCommit: "Fixup commit", RewordCommit: "Reword commit", DropCommit: "Drop commit", EditCommit: "Edit commit", AmendCommit: "Amend commit", ResetCommitAuthor: "Reset commit author", SetCommitAuthor: "Set commit author", AddCommitCoAuthor: "Add commit co-author", RevertCommit: "Revert commit", CreateFixupCommit: "Create fixup commit", SquashAllAboveFixupCommits: "Squash all above fixup commits", CreateLightweightTag: "Create lightweight tag", CreateAnnotatedTag: "Create annotated tag", CopyCommitMessageToClipboard: "Copy commit message to clipboard", CopyCommitMessageBodyToClipboard: "Copy commit message body to clipboard", CopyCommitSubjectToClipboard: "Copy commit subject to clipboard", CopyCommitTagsToClipboard: "Copy commit tags to clipboard", CopyCommitDiffToClipboard: "Copy commit diff to clipboard", CopyCommitHashToClipboard: "Copy full commit hash to clipboard", CopyCommitURLToClipboard: "Copy commit URL to clipboard", CopyCommitAuthorToClipboard: "Copy commit author to clipboard", CopyCommitAttributeToClipboard: "Copy to clipboard", CopyPatchToClipboard: "Copy patch to clipboard", MoveCommitUp: "Move commit up", MoveCommitDown: "Move commit down", CustomCommand: "Custom command", // TODO: remove DiscardAllChangesInDirectory: "Discard all changes in directory", DiscardUnstagedChangesInDirectory: "Discard unstaged changes in directory", DiscardAllChangesInFile: "Discard all changes in selected file(s)", DiscardAllUnstagedChangesInFile: "Discard all unstaged changes selected file(s)", StageFile: "Stage file", StageResolvedFiles: "Stage files whose merge conflicts were resolved", UnstageFile: "Unstage file", UnstageAllFiles: "Unstage all files", StageAllFiles: "Stage all files", ResolveConflictByKeepingFile: "Resolve by keeping file", ResolveConflictByDeletingFile: "Resolve by deleting file", NotEnoughContextToStage: "Staging or unstaging changes is not possible with a diff context size of 0. Increase the context using '%s'.", NotEnoughContextToDiscard: "Discarding changes is not possible with a diff context size of 0. Increase the context using '%s'.", NotEnoughContextForCustomPatch: "Creating custom patches is not possible with a diff context size of 0. Increase the context using '%s'.", IgnoreExcludeFile: "Ignore or exclude file", IgnoreFileErr: "Cannot ignore .gitignore", ExcludeFile: "Exclude file", ExcludeGitIgnoreErr: "Cannot exclude .gitignore", Commit: "Commit", EditFile: "Edit file", Push: "Push", Pull: "Pull", OpenFile: "Open file", StashAllChanges: "Stash all changes", StashAllChangesKeepIndex: "Stash all changes and keep index", StashStagedChanges: "Stash staged changes", StashUnstagedChanges: "Stash unstaged changes", StashIncludeUntrackedChanges: "Stash all changes including untracked files", GitFlowFinish: "git flow finish", GitFlowStart: "git flow start", CopyToClipboard: "Copy to clipboard", CopySelectedTextToClipboard: "Copy selected text to clipboard", RemovePatchFromCommit: "Remove patch from commit", MovePatchToSelectedCommit: "Move patch to selected commit", MovePatchIntoIndex: "Move patch into index", MovePatchIntoNewCommit: "Move patch into new commit", DeleteRemoteBranch: "Delete remote branch", SetBranchUpstream: "Set branch upstream", AddRemote: "Add remote", RemoveRemote: "Remove remote", UpdateRemote: "Update remote", ApplyPatch: "Apply patch", Stash: "Stash", RenameStash: "Rename stash", RemoveSubmodule: "Remove submodule", ResetSubmodule: "Reset submodule", AddSubmodule: "Add submodule", UpdateSubmoduleUrl: "Update submodule URL", InitialiseSubmodule: "Initialise submodule", BulkInitialiseSubmodules: "Bulk initialise submodules", BulkUpdateSubmodules: "Bulk update submodules", BulkDeinitialiseSubmodules: "Bulk deinitialise submodules", BulkUpdateRecursiveSubmodules: "Bulk initialise and update submodules recursively", UpdateSubmodule: "Update submodule", DeleteLocalTag: "Delete local tag", DeleteRemoteTag: "Delete remote tag", PushTag: "Push tag", NukeWorkingTree: "Nuke working tree", DiscardUnstagedFileChanges: "Discard unstaged file changes", RemoveUntrackedFiles: "Remove untracked files", RemoveStagedFiles: "Remove staged files", SoftReset: "Soft reset", MixedReset: "Mixed reset", HardReset: "Hard reset", FastForwardBranch: "Fast forward branch", AutoForwardBranches: "Auto-forward branches", Undo: "Undo", Redo: "Redo", CopyPullRequestURL: "Copy pull request URL", OpenDiffTool: "Open diff tool", OpenMergeTool: "Open merge tool", OpenCommitInBrowser: "Open commit in browser", OpenPullRequest: "Open pull request in browser", StartBisect: "Start bisect", ResetBisect: "Reset bisect", BisectSkip: "Bisect skip", BisectMark: "Bisect mark", RemoveWorktree: "Remove worktree", AddWorktree: "Add worktree", }, Bisect: Bisect{ Mark: "Mark current commit (%s) as %s", MarkStart: "Mark %s as %s (start bisect)", SkipCurrent: "Skip current commit (%s)", SkipSelected: "Skip selected commit (%s)", ResetTitle: "Reset 'git bisect'", ResetPrompt: "Are you sure you want to reset 'git bisect'?", ResetOption: "Reset bisect", ChooseTerms: "Choose bisect terms", OldTermPrompt: "Term for old/good commit:", NewTermPrompt: "Term for new/bad commit:", BisectMenuTitle: "Bisect", CompleteTitle: "Bisect complete", CompletePrompt: "Bisect complete! The following commit introduced the change:\n\n%s\n\nDo you want to reset 'git bisect' now?", CompletePromptIndeterminate: "Bisect complete! Some commits were skipped, so any of the following commits may have introduced the change:\n\n%s\n\nDo you want to reset 'git bisect' now?", Bisecting: "Bisecting", }, Log: Log{ EditRebase: "Beginning interactive rebase at '{{.ref}}'", MoveCommitUp: "Moving TODO down: '{{.shortHash}}'", MoveCommitDown: "Moving TODO down: '{{.shortHash}}'", CherryPickCommits: "Cherry-picking commits:\n'{{.commitLines}}'", HandleUndo: "Undoing last conflict resolution", HandleMidRebaseCommand: "Updating rebase action of commit {{.shortHash}} to '{{.action}}'", RemoveFile: "Deleting path '{{.path}}'", CopyToClipboard: "Copying '{{.str}}' to clipboard", Remove: "Removing '{{.filename}}'", CreateFileWithContent: "Creating file '{{.path}}'", AppendingLineToFile: "Appending '{{.line}}' to file '{{.filename}}'", EditRebaseFromBaseCommit: "Beginning interactive rebase from '{{.baseCommit}}' onto '{{.targetBranchName}}", }, BreakingChangesTitle: "Breaking Changes", BreakingChangesMessage: `You are updating to a new version of lazygit which contains breaking changes. Please review the notes below and update your configuration if necessary. For more information, see the full release notes at .`, BreakingChangesByVersion: map[string]string{ "0.41.0": `- When you press 'g' to bring up the git reset menu, the 'mixed' option is now the first and default, rather than 'soft'. This is because 'mixed' is the most commonly used option. - The commit message panel now automatically hard-wraps by default (i.e. it adds newline characters when you reach the margin). You can adjust the config like so: git: commit: autoWrapCommitMessage: true autoWrapWidth: 72 - The 'v' key was already being used in the staging view to start a range select, but now you can use it to start a range select in any view. Unfortunately this clashes with the 'v' keybinding for pasting commits (cherry-pick), so now pasting commits is done via 'shift+V' and for the sake of consistency, copying commits is now done via 'shift+C' instead of just 'c'. Note that the 'v' keybinding is only one way to start a range-select: you can use shift+up/down arrow instead. So, if you want to configure the cherry-pick keybindings to get the old behaviour, set the following in your config: keybinding: universal: toggleRangeSelect: commits: cherryPickCopy: 'c' pasteCommits: 'v' - Squashing fixups using 'shift-S' now brings up a menu, with the default option being to squash all fixup commits in the branch. The original behaviour of only squashing fixup commits above the selected commit is still available as the second option in that menu. - Push/pull/fetch loading statuses are now shown against the branch rather than in a popup. This allows you to e.g. fetch multiple branches in parallel and see the status for each branch. - The git log graph in the commits view is now always shown by default (previously it was only shown when the view was maximised). If you find this too noisy, you can change it back via ctrl+L -> 'Show git graph' -> 'when maximised' - Pressing space on a remote branch used to show a prompt for entering a name for a new local branch to check out from the remote branch. Now it just checks out the remote branch directly, letting you choose between a new local branch with the same name, or a detached head. The old behavior is still available via the 'n' keybinding. - Filtering (e.g. when pressing '/') is less fuzzy by default; it only matches substrings now. Multiple substrings can be matched by separating them with spaces. If you want to revert to the old behavior, set the following in your config: gui: filterMode: 'fuzzy' `, "0.44.0": `- The gui.branchColors config option is deprecated; it will be removed in a future version. Please use gui.branchColorPatterns instead. - The automatic coloring of branches starting with "feature/", "bugfix/", or "hotfix/" has been removed; if you want this, it's easy to set up using the new gui.branchColorPatterns option.`, "0.49.0": `- Executing shell commands (with the ':' prompt) no longer uses an interactive shell, which means that if you want to use your shell aliases in this prompt, you need to do a little bit of setup work. See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#using-aliases-or-functions-in-shell-commands for details.`, "0.50.0": `- After fetching, main branches now get auto-forwarded to their upstream if they fall behind. This is useful for keeping your main or master branch up to date automatically. If you don't want this, you can disable it by setting the following in your config: git: autoForwardBranches: none If, on the other hand, you want this even for feature branches, you can set it to 'allBranches' instead.`, }, } } lazygit-0.50.0+ds1/pkg/i18n/i18n.go000066400000000000000000000060431500612110400164010ustar00rootroot00000000000000package i18n import ( "embed" "encoding/json" "fmt" "io/fs" "strings" "dario.cat/mergo" "github.com/cloudfoundry/jibber_jabber" "github.com/go-errors/errors" "github.com/samber/lo" "github.com/sirupsen/logrus" ) func NewTranslationSetFromConfig(log *logrus.Entry, configLanguage string) (*TranslationSet, error) { languageCodes, err := getSupportedLanguageCodes() if err != nil { return nil, err } if configLanguage == "auto" { language := detectLanguage(jibber_jabber.DetectIETF) for _, languageCode := range languageCodes { if strings.HasPrefix(language, languageCode) { return newTranslationSet(log, languageCode) } } // Detecting a language that we don't have a translation for is not an // error, we'll just use English. return EnglishTranslationSet(), nil } if configLanguage == "en" { return EnglishTranslationSet(), nil } for _, key := range languageCodes { if key == configLanguage { return newTranslationSet(log, configLanguage) } } // Configuring a language that we don't have a translation for *is* an // error, though. return nil, errors.New("Language not found: " + configLanguage) } func newTranslationSet(log *logrus.Entry, language string) (*TranslationSet, error) { log.Info("language: " + language) baseSet := EnglishTranslationSet() if language != "en" { translationSet, err := readLanguageFile(language) if err != nil { return nil, err } err = mergo.Merge(baseSet, *translationSet, mergo.WithOverride) if err != nil { return nil, err } } return baseSet, nil } //go:embed translations/*.json var embedFS embed.FS // getSupportedLanguageCodes gets all the supported language codes. // Note: this doesn't include "en" func getSupportedLanguageCodes() ([]string, error) { dirEntries, err := embedFS.ReadDir("translations") if err != nil { return nil, err } return lo.Map(dirEntries, func(entry fs.DirEntry, _ int) string { return strings.TrimSuffix(entry.Name(), ".json") }), nil } func readLanguageFile(languageCode string) (*TranslationSet, error) { jsonData, err := embedFS.ReadFile(fmt.Sprintf("translations/%s.json", languageCode)) if err != nil { return nil, err } var translationSet TranslationSet err = json.Unmarshal(jsonData, &translationSet) if err != nil { return nil, err } return &translationSet, nil } // GetTranslationSets gets all the translation sets, keyed by language code // This includes "en". func GetTranslationSets() (map[string]*TranslationSet, error) { languageCodes, err := getSupportedLanguageCodes() if err != nil { return nil, err } result := make(map[string]*TranslationSet) result["en"] = EnglishTranslationSet() for _, languageCode := range languageCodes { translationSet, err := readLanguageFile(languageCode) if err != nil { return nil, err } result[languageCode] = translationSet } return result, nil } // detectLanguage extracts user language from environment func detectLanguage(langDetector func() (string, error)) string { if userLang, err := langDetector(); err == nil { return userLang } return "C" } lazygit-0.50.0+ds1/pkg/i18n/i18n_test.go000066400000000000000000000051101500612110400174320ustar00rootroot00000000000000package i18n import ( "fmt" "io" "runtime" "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) // TestDetectLanguage is a function. func TestDetectLanguage(t *testing.T) { type scenario struct { langDetector func() (string, error) expected string } scenarios := []scenario{ { func() (string, error) { return "", fmt.Errorf("An error occurred") }, "C", }, { func() (string, error) { return "en", nil }, "en", }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, detectLanguage(s.langDetector)) } } // Can't use utils.NewDummyLog() because of a cyclic dependency func newDummyLog() *logrus.Entry { log := logrus.New() log.Out = io.Discard return log.WithField("test", "test") } func TestNewTranslationSetFromConfig(t *testing.T) { if runtime.GOOS == "windows" { // These tests are based on setting the LANG environment variable, which // isn't respected on Windows. t.Skip("Skipping test on Windows") } scenarios := []struct { name string configLanguage string envLanguage string expected string expectedErr bool }{ { name: "configLanguage is nl", configLanguage: "nl", envLanguage: "en_US", expected: "nl", expectedErr: false, }, { name: "configLanguage is an unsupported language", configLanguage: "xy", envLanguage: "en_US", expectedErr: true, }, { name: "auto-detection without LANG set", configLanguage: "auto", envLanguage: "", expected: "en", expectedErr: false, }, { name: "auto-detection with LANG set to nl_NL", configLanguage: "auto", envLanguage: "nl_NL", expected: "nl", expectedErr: false, }, { name: "auto-detection with LANG set to zh-CN", configLanguage: "auto", envLanguage: "zh-CN", expected: "zh-CN", expectedErr: false, }, { name: "auto-detection with LANG set to an unsupported language", configLanguage: "auto", envLanguage: "xy_XY", expected: "en", expectedErr: false, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { log := newDummyLog() t.Setenv("LANG", s.envLanguage) actualTranslationSet, err := NewTranslationSetFromConfig(log, s.configLanguage) if s.expectedErr { assert.Error(t, err) } else { assert.NoError(t, err) expectedTranslationSet, _ := newTranslationSet(log, s.expected) assert.Equal(t, expectedTranslationSet, actualTranslationSet) } }) } } lazygit-0.50.0+ds1/pkg/i18n/translations/000077500000000000000000000000001500612110400200115ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/i18n/translations/README.md000066400000000000000000000015061500612110400212720ustar00rootroot00000000000000The JSON files in this directory are machine-generated; please do not edit. Translating lazygit happens at https://crowdin.com/project/lazygit/. # Updating translations from Crowdin We regularly need to pull changes from Crowdin and integrate them here. This is done by downloading a zip file of the translations from Crowdin, unzipping it, and calling `scripts/update_language_files.sh` with the unzipped directory as an argument. # Uploading the English file to Crowdin The English version of all the texts is still maintained in `pkg/i18n/english.go`; it needs to be uploaded to Crowdin regularly. To do this, call `go run cmd/i18n/main.go`; this will create an unversioned file `en.json` in the root of the repository. Upload this to `https://crowdin.com/project/lazygit/sources/files` and delete it from the working copy again. lazygit-0.50.0+ds1/pkg/i18n/translations/ja.json000066400000000000000000000544071500612110400213100ustar00rootroot00000000000000{ "NotEnoughSpace": "パネルの描画に十分な空間がありません", "DiffTitle": "差分", "FilesTitle": "ファイル", "BranchesTitle": "ブランチ", "CommitsTitle": "コミット", "EasterEgg": "イースターエッグ", "UnstagedChanges": "ステージされていない変更", "StagedChanges": "ステージされた変更", "MainTitle": "メイン", "StagingTitle": "メインパネル (Staging)", "MergingTitle": "メインパネル (Merging)", "NormalTitle": "メインパネル (Normal)", "LogTitle": "ログ", "CommitSummary": "コミットメッセージ", "CredentialsUsername": "ユーザ名", "CredentialsPassword": "パスワード", "CredentialsPassphrase": "SSH鍵のパスフレーズを入力", "PassUnameWrong": "パスワード, パスフレーズまたはユーザ名が間違っています。", "Commit": "変更をコミット", "AmendLastCommit": "最新のコミットにamend", "AmendLastCommitTitle": "最新のコミットにamend", "SureToAmend": "最新のコミットに変更をamendします。よろしいですか? コミットメッセージはコミットパネルから変更できます。", "NoCommitToAmend": "Amend可能なコミットが存在しません。", "CommitChangesWithEditor": "gitエディタを使用して変更をコミット", "StatusTitle": "ステータス", "GlobalTitle": "グローバルキーバインド", "Menu": "メニュー", "Execute": "実行", "Stage": "ステージ/アンステージ", "ToggleStagedAll": "すべての変更をステージ/アンステージ", "ToggleTreeView": "ファイルツリーの表示を切り替え", "OpenMergeTool": "Git mergetoolを開く", "Refresh": "リフレッシュ", "Scroll": "スクロール", "FileFilter": "ファイルをフィルタ (ステージ/アンステージ)", "FilterStagedFiles": "ステージされたファイルのみを表示", "FilterUnstagedFiles": "ステージされていないファイルのみを表示", "MergeConflictsTitle": "マージコンフリクト", "Checkout": "チェックアウト", "NoChangedFiles": "変更されたファイルはありません", "SoftReset": "Softリセット", "AlreadyCheckedOutBranch": "ブランチはすでにチェックアウトされています。", "BranchName": "ブランチ名", "NewBranchNameBranchOff": "新規ブランチ名 ('{{.branchName}}' に作成)", "CantDeleteCheckOutBranch": "チェックアウト中のブランチは削除できません!", "DeleteLocalBranch": "ローカルブランチを削除", "DeleteLocalBranches": "ローカルブランチを削除", "DeleteRemoteBranchOption": "リモートブランチを削除", "ForceDeleteBranchTitle": "強制的にブランチを削除", "CantRebaseOntoSelf": "ブランチを自分自身にリベースすることはできません。", "CantMergeBranchIntoItself": "ブランチを自分自身にマージすることはできません。", "ForceCheckout": "強制的にチェックアウト", "RemoteBranchCheckoutTitle": "{{.branchName}} をチェックアウト", "NewBranch": "新しいブランチを作成", "NoBranchesThisRepo": "リポジトリにブランチが存在しません", "CommitWithoutMessageErr": "コミットメッセージを入力してください", "Close": "閉じる", "CloseCancel": "閉じる/キャンセル", "Confirm": "確認", "Quit": "終了", "Pick": "選択", "Edit": "編集", "RevertCommit": "コミットをrevert", "Reword": "コミットメッセージを変更", "DropCommit": "コミットを削除", "MoveDownCommit": "コミットを1つ下に移動", "MoveUpCommit": "コミットを1つ上に移動", "EditCommitTooltip": "コミットを編集", "AmendCommitTooltip": "ステージされた変更でamendコミット", "RewordCommitEditor": "エディタでコミットメッセージを編集", "Error": "エラー", "Undo": "アンドゥ", "UndoReflog": "アンドゥ (via reflog) (experimental)", "RedoReflog": "リドゥ (via reflog) (experimental)", "Apply": "適用", "NoStashEntries": "Stashが存在しません", "StashDrop": "Stashを削除", "StashPop": "Stashをpop", "SurePopStashEntry": "Stashをpopします。よろしいですか?", "StashApply": "Stashを適用", "SureApplyStashEntry": "Stashを適用します。よろしいですか?", "StashChanges": "変更をStash", "RenameStash": "Stashを変更", "RenameStashPrompt": "Stash名を変更: {{.stashName}}", "OpenConfig": "設定ファイルを開く", "EditConfig": "設定ファイルを編集", "ForcePushPrompt": "ブランチがリモートブランチから分岐しています。'esc'でキャンセル, または'enter'でforce pushします。", "ForcePushDisabled": "ブランチがリモートブランチから分岐しています。force pushは無効化されています。", "CheckForUpdate": "更新を確認", "CheckingForUpdates": "更新を確認中...", "UpdateAvailableTitle": "最新リリース!", "UpdateAvailable": "バージョン {{.newVersion}} をインストールしますか?", "UpdateInProgressWaitingStatus": "更新中", "UpdateCompletedTitle": "更新完了!", "UpdateCompleted": "更新のインストールに成功しました。lazygitを再起動してください。", "FailedToRetrieveLatestVersionErr": "バージョン情報の取得に失敗しました", "OnLatestVersionErr": "使用中のバージョンは最新です", "MajorVersionErr": "新バージョン ({{.newVersion}}) は現在のバージョン ({{.currentVersion}}) と後方互換性がありません。", "CouldNotFindBinaryErr": "{{.url}} にバイナリが存在しませんでした。", "UpdateFailedErr": "更新失敗: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "現在更新中", "ConfirmQuitDuringUpdate": "現在更新を実行中です。終了しますか?", "MergeToolTitle": "マージツール", "MergeToolPrompt": "`git mergetool`を開きます。よろしいですか?", "EditFile": "ファイルを編集", "OpenFile": "ファイルを開く", "IgnoreFile": ".gitignoreに追加", "RefreshFiles": "ファイルをリフレッシュ", "Merge": "現在のブランチにマージ", "ConfirmQuit": "終了します。よろしいですか?", "SwitchRepo": "最近使用したリポジトリに切り替え", "UnsupportedGitService": "サポートされていないGitサービスです。", "CopyPullRequestURL": "Pull RequestのURLをクリップボードにコピー", "NoBranchOnRemote": "ブランチがリモートに存在しません。リモートにpushしてください。", "StageSelectionTooltip": "選択行をステージ/アンステージ", "DiscardSelection": "変更を削除 (git reset)", "ToggleSelectHunk": "Hunk選択を切り替え", "ToggleSelectionForPatch": "行をパッチに追加/削除", "ToggleStagingView": "パネルを切り替え", "ReturnToFilesPanel": "ファイル一覧に戻る", "RecentRepos": "最近使用したリポジトリ", "CommitSummaryTitle": "コミットメッセージ", "LocalBranchesTitle": "ブランチ", "SearchTitle": "検索", "TagsTitle": "タグ", "MenuTitle": "メニュー", "RemotesTitle": "リモート", "RemoteBranchesTitle": "リモートブランチ", "PatchBuildingTitle": "メインパネル (Patch Building)", "ReflogCommitsTitle": "参照ログ", "ErrorOccurred": "エラーが発生しました! issueを作成してください: ", "YouAreHere": "現在位置", "CherryPickCopy": "コミットをコピー (cherry-pick)", "PasteCommits": "コミットを貼り付け (cherry-pick)", "CherryPick": "Cherry-Pick", "Donate": "支援", "AskQuestion": "質問", "PrevLine": "前の行を選択", "NextLine": "次の行を選択", "PrevHunk": "前のhunkを選択", "NextHunk": "次のhunkを選択", "PrevConflict": "前のコンフリクトを選択", "NextConflict": "次のコンフリクトを選択", "SelectPrevHunk": "前のhunkを選択", "SelectNextHunk": "次のhunkを選択", "ScrollDown": "下にスクロール", "ScrollUp": "上にスクロール", "ScrollUpMainWindow": "メインパネルを上にスクロール", "ScrollDownMainWindow": "メインパネルを下にスクロール", "AmendCommitTitle": "Amendコミット", "AmendCommitPrompt": "ステージされたファイルで現在のコミットをamendします。よろしいですか?", "DropCommitTitle": "コミットを削除", "DropCommitPrompt": "選択されたコミットを削除します。よろしいですか?", "PullingStatus": "Pull中", "PushingStatus": "Push中", "FetchingStatus": "Fetch中", "SubCommitsDynamicTitle": "コミット (%s)", "RemoteBranchesDynamicTitle": "リモートブランチ (%s)", "CommitFilesTitle": "コミットファイル", "DiscardFileChangesTitle": "ファイルの変更を破棄", "CreateRepo": "Gitリポジトリではありません。リポジトリを作成しますか? (y/n): ", "Cancel": "キャンセル", "DiscardAllChanges": "すべての変更を破棄", "HardReset": "hardリセット", "CreateFixupCommit": "Fixupコミットを作成", "CreateFixupCommitTooltip": "このコミットに対するfixupコミットを作成", "CommitChangesWithoutHook": "pre-commitフックを実行せずに変更をコミット", "PressEnterToReturn": "Enterを入力してください", "StashAllChanges": "変更をstash", "Jump": "パネルに移動", "ScrollLeftRight": "左右にスクロール", "ScrollLeft": "左スクロール", "ScrollRight": "右スクロール", "DiscardPatch": "パッチを破棄", "EnterUpstream": "' ' の形式でupstreamを入力", "InvalidUpstream": "Upstreamの形式が正しくありません。' ' の形式で入力してください。", "ReturnToRemotesList": "リモート一覧に戻る", "NewRemote": "リモートを新規追加", "NewRemoteName": "新規リモート名:", "NewRemoteUrl": "新規リモートURL:", "EditRemoteName": "{{.remoteName}} の新しいリモート名を入力:", "EditRemoteUrl": "{{.remoteName}} の新しいリモートURLを入力:", "RemoveRemote": "リモートを削除", "DeleteRemoteBranch": "リモートブランチを削除", "EditRemoteTooltip": "リモートを編集", "TagCommit": "タグを作成", "TagMenuTitle": "タグを作成", "TagNameTitle": "タグ名", "TagMessageTitle": "タグメッセージ", "LightweightTag": "軽量タグ", "AnnotatedTag": "注釈付きタグ", "PushTagTitle": "リモートにタグ '{{.tagName}}' をpush", "PushTag": "タグをpush", "NewTag": "タグを作成", "FetchRemoteTooltip": "リモートをfetch", "FetchingRemoteStatus": "リモートをfetch", "CheckoutCommit": "コミットをチェックアウト", "SureCheckoutThisCommit": "選択されたコミットをチェックアウトします。よろしいですか?", "NewBranchNamePrompt": "新しいブランチ名を入力", "NextScreenMode": "次のスクリーンモード (normal/half/fullscreen)", "PrevScreenMode": "前のスクリーンモード", "StartSearch": "検索を開始", "Panel": "パネル", "Keybindings": "キーバインド", "RenameBranch": "ブランチ名を変更", "OpenKeybindingsMenu": "メニューを開く", "NextTab": "次のタブ", "PrevTab": "前のタブ", "CantUndoWhileRebasing": "リベース中はアンドゥできません。", "CantRedoWhileRebasing": "リベース中はリドゥできません。", "ConfirmationTitle": "確認パネル", "PrevPage": "前のページ", "NextPage": "次のページ", "GotoTop": "最上部までスクロール", "GotoBottom": "最下部までスクロール", "Diff": "差分", "EnterRefName": "参照を入力:", "ExitDiffMode": "差分モードを終了", "DiffingMenuTitle": "差分", "ViewDiffingOptions": "差分メニューを開く", "OpenCommandLogMenu": "コマンドログメニューを開く", "CommitDiff": "コミットの差分", "CopyCommitHashToClipboard": "コミットのhashをクリップボードにコピー", "CommitHash": "コミットのhash", "CommitURL": "コミットのURL", "CopyCommitMessageToClipboard": "コミットメッセージをクリップボードにコピー", "CommitMessage": "コミットメッセージ", "CommitAuthor": "コミットの作成者名", "CopyCommitAttributeToClipboard": "コミットの情報をコピー", "CopyBranchNameToClipboard": "ブランチ名をクリップボードにコピー", "CopyPathToClipboard": "ファイル名をクリップボードにコピー", "CopySelectedTextToClipboard": "選択されたテキストをクリップボードにコピー", "NoFilesStagedTitle": "ファイルがステージされていません", "NoFilesStagedPrompt": "ファイルがステージされていません。すべての変更をコミットしますか?", "BranchNotFoundTitle": "ブランチが見つかりませんでした。", "BranchNotFoundPrompt": "ブランチが見つかりませんでした。新しくブランチを作成します ", "DiscardChangeTitle": "選択行をアンステージ", "DiscardChangePrompt": "選択された行を削除 (git reset) します。よろしいですか? この操作は取り消せません。\nこの警告を無効化するには設定ファイルの 'gui.skipDiscardChangeWarning' を true に設定してください。", "CreateNewBranchFromCommit": "コミットにブランチを作成", "BuildingPatch": "パッチを構築", "ViewCommits": "コミットを閲覧", "RunningCustomCommandStatus": "カスタムコマンドを実行", "EnterSubmoduleTooltip": "サブモジュールを開く", "CopySubmoduleNameToClipboard": "サブモジュール名をクリップボードにコピー", "RemoveSubmodule": "サブモジュールを削除", "RemoveSubmodulePrompt": "サブモジュール '%s' とそのディレクトリを削除します。よろしいですか? この操作は取り消せません。", "ResettingSubmoduleStatus": "サブモジュールをリセット", "NewSubmoduleName": "新規サブモジュール名:", "NewSubmoduleUrl": "新規サブモジュールのURL:", "NewSubmodulePath": "新規サブモジュールのパス:", "NewSubmodule": "サブモジュールを新規追加", "AddingSubmoduleStatus": "サブモジュールを新規追加", "UpdateSubmoduleUrl": "サブモジュール '%s' のURLを更新", "UpdatingSubmoduleUrlStatus": "URLを更新", "EditSubmoduleUrl": "サブモジュールのURLを更新", "InitializingSubmoduleStatus": "サブモジュールを初期化", "InitSubmoduleTooltip": "サブモジュールを初期化", "SubmoduleUpdateTooltip": "サブモジュールを更新", "UpdatingSubmoduleStatus": "サブモジュールを更新", "BulkInitSubmodules": "サブモジュールを一括初期化", "BulkUpdateSubmodules": "サブモジュールを一括更新", "SubmodulesTitle": "サブモジュール", "NavigationTitle": "一覧パネルの操作", "ExtrasTitle": "コマンドログ", "PullRequestURLCopiedToClipboard": "Pull requestのURLがクリップボードにコピーされました", "CommitDiffCopiedToClipboard": "コミットの差分がクリップボードにコピーされました", "CommitURLCopiedToClipboard": "コミットのURLがクリップボードにコピーされました", "CommitMessageCopiedToClipboard": "コミットメッセージがクリップボードにコピーされました", "CommitAuthorCopiedToClipboard": "コミットの作成者名がクリップボードにコピーされました", "CopiedToClipboard": "クリップボードにコピーされました", "ErrCannotEditDirectory": "ディレクトリは編集できません。", "ErrStageDirWithInlineMergeConflicts": "マージコンフリクトの発生したファイルを含むディレクトリはステージ/アンステージできません。マージコンフリクトを解決してください。", "ErrRepositoryMovedOrDeleted": "リポジトリが見つかりません。すでに削除されたか、移動された可能性があります ¯\\_(ツ)_/¯", "CommandLog": "コマンドログ", "ToggleShowCommandLog": "コマンドログの表示/非表示を切り替え", "FocusCommandLog": "コマンドログにフォーカス", "CommandLogHeader": "コマンドログの表示/非表示は '%s' で切り替えられます。\n", "RandomTip": "ランダムTips", "ToggleWhitespaceInDiffView": "空白文字の差分の表示有無を切り替え", "DefaultBranch": "デフォルトブランチ", "SelectBranch": "ブランチを選択", "CreatePullRequest": "Pull Requestを作成", "SelectConfigFile": "設定ファイルを選択", "NoConfigFileFoundErr": "設定ファイルが見つかりませんでした。", "AbortTitle": "%sを中止", "AbortPrompt": "実施中の%sを中止します。よろしいですか?", "OpenLogMenu": "ログメニューを開く", "LogMenuTitle": "コミットログオプション", "ShowGitGraph": "コミットグラフの表示", "SortOrder": "並び替え", "SortAlphabetical": "アルファベット順", "SortByDate": "日付順", "SortCommits": "コミットの表示順", "OpenCommitInBrowser": "ブラウザでコミットを開く", "RewordInEditorTitle": "コミットメッセージをエディタで編集", "ToggleRangeSelect": "範囲選択を切り替え", "Actions": { "CheckoutCommit": "コミットをチェックアウト", "CheckoutTag": "タグをチェックアウト", "CheckoutBranch": "ブランチをチェックアウト", "ForceCheckoutBranch": "ブランチを強制的にチェックアウト", "Merge": "マージ", "RenameBranch": "ブランチ名を変更", "CreateBranch": "ブランチを作成", "FastForwardBranch": "ブランチをfast forward", "CheckoutFile": "ファイルをチェックアウトs", "FixupCommit": "Fixupコミット", "RewordCommit": "コミットメッセージを変更", "DropCommit": "コミットを削除", "EditCommit": "コミットを編集", "AmendCommit": "Amendコミット", "RevertCommit": "コミットをrevert", "CreateFixupCommit": "fixupコミットを作成", "MoveCommitUp": "コミットを上に移動", "MoveCommitDown": "コミットを下に移動", "CopyCommitMessageToClipboard": "コミットメッセージをクリップボードにコピー", "CopyCommitDiffToClipboard": "コミットの差分をクリップボードにコピー", "CopyCommitHashToClipboard": "コミットhashをクリップボードにコピー", "CopyCommitURLToClipboard": "コミットのURLをクリップボードにコピー", "CopyCommitAuthorToClipboard": "コミットの作成者名をクリップボードにコピー", "CopyCommitAttributeToClipboard": "クリップボードにコピー", "CustomCommand": "カスタムコマンド", "DiscardAllChangesInDirectory": "ディレクトリ内のすべての変更を破棄", "DiscardUnstagedChangesInDirectory": "ディレクトリ内のすべてのステージされていない変更を破棄", "DiscardAllChangesInFile": "ファイル内のすべての変更を破棄", "DiscardAllUnstagedChangesInFile": "ファイル内のすべてのステージされていない変更を破棄", "StageFile": "ファイルをステージ", "StageResolvedFiles": "マージコンフリクトが解決されたすべてのファイルをステージ", "UnstageFile": "ファイルをアンステージ", "UnstageAllFiles": "すべてのファイルをアンステージ", "StageAllFiles": "すべてのファイルをステージ", "IgnoreExcludeFile": "ファイルをignore", "Commit": "コミット", "EditFile": "ファイルを編集", "OpenFile": "ファイルを開く", "StashAllChanges": "すべての変更をStash", "StashStagedChanges": "ステージされた変更をStash", "GitFlowFinish": "Git flow finish", "GitFlowStart": "Git Flow start", "CopyToClipboard": "クリップボードにコピー", "CopySelectedTextToClipboard": "選択されたテキストをクリップボードにコピー", "RemovePatchFromCommit": "パッチをコミットから削除", "MovePatchToSelectedCommit": "パッチを選択したコミットに移動", "MovePatchIntoIndex": "パッチをindexに移動", "MovePatchIntoNewCommit": "パッチを次のコミットに移動", "DeleteRemoteBranch": "リモートブランチを削除", "SetBranchUpstream": "Upstreamブランチを設定", "AddRemote": "リモートを追加", "RemoveRemote": "リモートを削除", "UpdateRemote": "リモートを更新", "ApplyPatch": "パッチを適用", "RenameStash": "Stash名を変更", "RemoveSubmodule": "サブモジュールを削除", "ResetSubmodule": "サブモジュールをリセット", "AddSubmodule": "サブモジュールを追加", "UpdateSubmoduleUrl": "サブモジュールのURLを更新", "InitialiseSubmodule": "サブモジュールを初期化", "BulkInitialiseSubmodules": "サブモジュールを一括初期化", "BulkUpdateSubmodules": "サブモジュールを一括更新", "UpdateSubmodule": "サブモジュールを更新", "CreateLightweightTag": "軽量タグを作成", "CreateAnnotatedTag": "注釈付きタグを作成", "PushTag": "タグをpush", "SoftReset": "Softリセット", "MixedReset": "Mixedリセット", "HardReset": "Hardリセット", "Undo": "アンドゥ", "Redo": "リドゥ", "CopyPullRequestURL": "Pull requestのURLをコピー", "OpenMergeTool": "マージツールを開く", "OpenCommitInBrowser": "コミットをブラウザで開く", "OpenPullRequest": "Pull requestをブラウザで開く", "StartBisect": "Bisectを開始", "ResetBisect": "Bisectをリセット", "BisectSkip": "Bisectをスキップ", "BisectMark": "Bisectをマーク" }, "Bisect": { "ResetTitle": "'git bisect' をリセット", "ResetPrompt": "'git bisect' をリセットします。よろしいですか?", "ResetOption": "Bisectをリセット", "BisectMenuTitle": "bisect", "SkipCurrent": "%s をスキップする", "CompleteTitle": "Bisect完了" }, "Log": {}, "BreakingChangesByVersion": {} } lazygit-0.50.0+ds1/pkg/i18n/translations/ko.json000066400000000000000000000553751500612110400213340ustar00rootroot00000000000000{ "NotEnoughSpace": "패널을 렌더링 할 공간이 부족합니다.", "DiffTitle": "변경점", "FilesTitle": "파일", "BranchesTitle": "브랜치", "CommitsTitle": "커밋", "EasterEgg": "이스터 에그", "UnstagedChanges": "Staged되지 않은 변경 내용", "StagedChanges": "Staged된 변경 내용", "MainTitle": "메인", "StagingTitle": "메인 패널 (Staging)", "MergingTitle": "메인 패널 (Merging)", "SquashMergeCommittedTitle": "스쿼시 병합 및 커밋", "NormalTitle": "메인 패널 (Normal)", "LogTitle": "로그", "CommitSummary": "커밋 메시지", "CredentialsUsername": "사용자 이름", "CredentialsPassword": "패스워드", "CredentialsPassphrase": "SSH키의 passphrase 입력", "CredentialsPIN": "SSH키\u001d의 PIN\u001d을 입력", "PassUnameWrong": "패스워드, passphrase 또는 사용자 이름이 잘못되었습니다.", "Commit": "커밋 변경내용", "AmendLastCommit": "마지맛 커밋 수정", "AmendLastCommitTitle": "마지막 커밋 수정", "SureToAmend": "마지막 커밋을 수정하시겠습니까? 그런 다음 커밋 패널에서 커밋 메시지를 변경할 수 있습니다.", "NoCommitToAmend": "Amend 가능한 커밋이 없습니다.", "CommitChangesWithEditor": "Git 편집기를 사용하여 변경 내용을 커밋합니다.", "StatusTitle": "상태", "GlobalTitle": "글로벌 키 바인딩", "Menu": "메뉴", "Execute": "실행", "Stage": "Staged 전환", "ToggleStagedAll": "모든 변경을 Staged/unstaged으로 전환", "ToggleTreeView": "파일 트리뷰로 전환", "OpenMergeTool": "Git mergetool를 열기", "Refresh": "새로고침", "Push": "푸시", "Pull": "업데이트", "Scroll": "스크롤", "FileFilter": "파일을 필터하기 (Staged/unstaged)", "CopyToClipboardMenu": "클립보드에 복사", "CopyFileName": "파일명", "CopySelectedDiff": "선택한 파일의 변경점", "CopyAllFilesDiff": "모든 파일의 변경점", "NoContentToCopyError": "복사 대상이 없습니다", "FileNameCopiedToast": "파일명을 클립보드에 복사했습니다.", "FilePathCopiedToast": "파일경로를 클립보드에 복사했습니다.", "FileDiffCopiedToast": "파일의 변경점을 클립보드에 복사했습니다.", "AllFilesDiffCopiedToast": "모든 파일의 변경점을 클립보드에 복사했습니다.", "FilterStagedFiles": "Staged된 파일만 표시", "FilterUnstagedFiles": "Stage되지 않은 파일만 표시", "MergeConflictsTitle": "병합 충돌 내용", "Checkout": "체크아웃", "NoChangedFiles": "변경된 파일이 없습니다.", "SoftReset": "소프트 리셋", "AlreadyCheckedOutBranch": "브랜치가 이미 체크아웃 되었습니다", "SureForceCheckout": "강제로 체크아웃하시겠습니까? 모든 로컬 변경 사항을 잃게 됩니다.", "ForceCheckoutBranch": "브랜치 강제 체크아웃", "BranchName": "브랜치 이름", "NewBranchNameBranchOff": "새 브랜치 이름 (branch is off of '{{.branchName}}')", "CantDeleteCheckOutBranch": "체크아웃하는 브랜치는 삭제할 수 없습니다!", "DeleteBranchTitle": "'{{.selectedBranchName}}' 브랜치를 삭제하시겠습니까?", "DeleteLocalBranch": "로컬 브랜치를 삭제", "DeleteRemoteBranchOption": "원격 브랜치를 삭제", "ForceDeleteBranchTitle": "브랜치를 강제 삭제", "ForceDeleteBranchMessage": "'{{.selectedBranchName}}'는 완전히 병합되지 않았습니다. 정말 삭제하시겠습니까?", "RebaseBranch": "체크아웃된 브랜치를 이 브랜치에 리베이스", "CantRebaseOntoSelf": "브랜치를 자기 자신에게 리베이스할 수는 없습니다.", "CantMergeBranchIntoItself": "브랜치를 자기 자신에게 병합할 수는 없습니다.", "ForceCheckout": "강제 체크아웃", "CheckoutByName": "이름으로 체크아웃", "NewBranch": "새 브랜치 생성", "NoBranchesThisRepo": "저장소에 브랜치가 존재하지 않습니다.", "CommitWithoutMessageErr": "커밋 메시지를 입력하세요.", "Close": "닫기", "CloseCancel": "닫기/취소", "Confirm": "확인", "Quit": "종료", "SureFixupThisCommit": "Are you sure you want to 'fixup' this commit? It will be merged into the commit below", "SureSquashThisCommit": "Are you sure you want to squash this commit into the commit below?", "Squash": "스쿼시", "SquashMerge": "스쿼시 병합", "PickCommitTooltip": "Pick commit (when mid-rebase)", "RevertCommit": "커밋 되돌리기", "Reword": "커밋메시지 변경", "DropCommit": "커밋 삭제", "MoveDownCommit": "커밋을 1개 아래로 이동", "MoveUpCommit": "커밋을 1개 위로 이동", "EditCommitTooltip": "커밋을 편집", "AmendCommitTooltip": "Amend commit with staged changes", "ResetAuthor": "Reset commit author", "RewordCommitEditor": "에디터에서 커밋메시지 수정", "NoCommitsThisBranch": "이 브랜치에 커밋이 없습니다.", "Error": "오류", "Undo": "되돌리기", "UndoReflog": "되돌리기 (reflog) (실험적)", "RedoReflog": "다시 실행 (reflog) (실험적)", "Apply": "적용", "NoStashEntries": "Stash가 존재하지 않습니다.", "StashDrop": "Stash를 삭제", "StashPop": "Stash를 pop", "SurePopStashEntry": "정말로 Stash를 pop하시겠습니까?", "StashApply": "Stash 적용", "SureApplyStashEntry": "정말로 Stash를 적용하시겠습니까?", "StashChanges": "변경을 Stash", "OpenConfig": "설정 파일 열기", "EditConfig": "설정 파일 수정", "ForcePush": "강제 푸시", "ForcePushPrompt": "브랜치가 원격 브랜치에서 분기하고 있습니다. 'esc'를 눌러 취소하거나, 'enter'를 눌러 강제로 푸시하세요.", "ForcePushDisabled": "브랜치가 원격 브랜치에서 분기하고 있습니다. force push가 비활성화 되었습니다.", "UpdatesRejectedAndForcePushDisabled": "업데이트가 거부되었으며 강제 푸시를 비활성화했습니다.", "CheckForUpdate": "업데이트 확인", "CheckingForUpdates": "업데이트 확인 중...", "UpdateAvailableTitle": "새로운 업데이트 사용가능!", "UpdateAvailable": "버전 {{.newVersion}} 을(를) 설치하시겠습니까?", "UpdateInProgressWaitingStatus": "업데이트 중", "UpdateCompletedTitle": "업데이트 완료!", "UpdateCompleted": "업데이트 설치에 성공했습니다. lazygit를 재시작해주세요.", "FailedToRetrieveLatestVersionErr": "버전 정보를 받아오는데 실패했습니다.", "OnLatestVersionErr": "이미 최신 버전을 사용하고 있습니다.", "MajorVersionErr": "새 버전 ({{.newVersion}}) 에 현재 버전({{.currentVersion}}) 과 비교할 때 호환되지 않는 변경 사항이 있습니다.", "CouldNotFindBinaryErr": "{{.url}} 에서 바이너리를 찾을 수 없습니다.", "UpdateFailedErr": "업데이트 실패: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "현재 업데이트 중입니다.", "ConfirmQuitDuringUpdate": "현재 업데이트를 진행 중입니다.종료하시겠습니까?", "MergeToolTitle": "병합 도구", "MergeToolPrompt": "정말로 `git mergetool`을 여시겠습니까?", "IntroPopupMessage": "\nlazygit!를 이용해주셔서 감사합니다. Seriously you rock. Three things to share with you:\n\n 1) lazygit의 기능에 대해 알아보려면 다음 비디오를 참조하세요.\n https://youtu.be/CPLdltN7wgE\n\n 2) 다음 사이트에서 최신 릴리스 노트를 읽어보세요.:\n https://github.com/jesseduffield/lazygit/releases\n\n 3) 만약 당신이 Git을 사용한다면, 그것은 당신을 프로그래머로 만들 것입니다!\n\t 당신의 도움으로 우리는 lazygit을 더 좋게 만들 수 있습니다, 그러니 기여자가 되는 것을 고려해보세요. 그리고 재미에 참여하세요:\n https://github.com/jesseduffield/lazygit\n 또한 오른쪽 하단의 기부 버튼을 클릭하여 저를 후원하고 작업할 내용을 알려주실 수 있습니다.\n 또는 저장소에 스타를 눌러 사랑을 공유할 수도 있습니다!\n", "GitconfigParseErr": "따옴표로 묶이지 않은 '\\' 문자가 있어서 Gogit이 gitconfig 파일을 분석하지 못했습니다. 이를 제거하면 문제가 해결됩니다.", "EditFile": "파일 편집", "OpenFile": "파일 닫기", "IgnoreFile": ".gitignore에 추가", "RefreshFiles": "파일 새로고침", "Merge": "현재 브랜치에 병합", "ConfirmQuit": "정말로 종료하시겠습니까?", "SwitchRepo": "최근에 사용한 저장소로 전환", "UnsupportedGitService": "지원되지 않는 Git 서비스입니다.", "CopyPullRequestURL": "풀 리퀘스트 URL을 클립보드에 복사", "NoBranchOnRemote": "브랜치가 원격에 없습니다. 원격에 먼저 푸시해야합니다.", "FileEnter": "Stage individual hunks/lines for file, or collapse/expand for directory", "FileStagingRequirements": "추적된 파일에 대해 개별 라인만 stage할 수 있습니다.", "StageSelectionTooltip": "선택한 행을 staged / unstaged", "DiscardSelection": "변경을 삭제 (git reset)", "ToggleSelectHunk": "Toggle select hunk", "ToggleSelectionForPatch": "Line(s)을 패치에 추가/삭제", "ToggleStagingView": "패널 전환", "ReturnToFilesPanel": "파일 목록으로 돌아가기", "FastForward": "Fast-forward this branch from its upstream", "FoundConflictsTitle": "Auto-merge failed", "RecentRepos": "최근에 사용한 저장소", "CommitSummaryTitle": "커밋메시지", "LocalBranchesTitle": "브랜치", "SearchTitle": "검색", "TagsTitle": "태그", "MenuTitle": "메뉴", "RemotesTitle": "원격", "RemoteBranchesTitle": "원격 브랜치", "PatchBuildingTitle": "메인 패널 (Patch Building)", "InformationTitle": "정보", "ConflictsResolved": "모든 병합 충돌이 해결되었습니다. 계속 할까요?", "ErrorOccurred": "오류가 발생했습니다! issue를 작성해 주세요: ", "YouAreHere": "현재 위치", "CherryPickCopy": "커밋을 복사 (cherry-pick)", "PasteCommits": "커밋을 붙여넣기 (cherry-pick)", "CherryPick": "체리픽", "Donate": "후원", "AskQuestion": "질문하기", "PrevLine": "이전 줄 선택", "NextLine": "다음 줄 선택", "PrevHunk": "이전 hunk를 선택", "NextHunk": "다음 hunk를 선택", "PrevConflict": "이전 충돌을 선택", "NextConflict": "다음 충돌을 선택", "SelectPrevHunk": "이전 hunk를 선택", "SelectNextHunk": "다음 hunk를 선택", "ScrollDown": "아래로 스크롤", "ScrollUp": "위로 스크롤", "ScrollUpMainWindow": "메인 패널을 위로 스크롤", "ScrollDownMainWindow": "메인 패널을 아래로로 스크롤", "DropCommitTitle": "커밋 삭제", "DropCommitPrompt": "정말로 선택한 커밋을 삭제하시겠습니까?", "PullingStatus": "업데이트 중", "PushingStatus": "푸시 중", "FetchingStatus": "패치 중", "SubCommitsDynamicTitle": "커밋 (%s)", "RemoteBranchesDynamicTitle": "원격브랜치 (%s)", "ViewItemFiles": "View selected item's files", "CommitFilesTitle": "커밋 파일", "CheckoutCommitFileTooltip": "Checkout file", "DiscardOldFileChangeTooltip": "Discard this commit's changes to this file", "DiscardFileChangesTitle": "파일 변경 사항 버리기", "DiscardFileChangesPrompt": "Are you sure you want to discard this commit's changes to this file? If this file was created in this commit, it will be deleted", "CreateRepo": "Git 저장소가 아닙니다. 저장소를 생성하시겠습니까? (y/n): ", "Discard": "View 'discard changes' options", "Cancel": "취소", "DiscardAllChanges": "모든 변경사항 버리기", "Delete": "삭제", "Reset": "초기화", "ViewResetOptions": "View reset options", "CreateFixupCommitTooltip": "Create fixup commit for this commit", "SquashAboveCommitsTooltip": "Squash all 'fixup!' commits above selected commit (autosquash)", "PressEnterToReturn": "엔터를 눌러 lazygit으로 돌아갑니다.", "ViewStashOptions": "Stash 옵션 보기", "StashAllChanges": "변경사항을 Stash", "StashOptions": "Stash 옵션", "Jump": "패널로 이동", "ScrollLeftRight": "좌우로 스크롤", "ScrollLeft": "우 스크롤", "ScrollRight": "좌 스크롤", "DiscardPatch": "Patch 버리기", "ToggleAllInPatch": "Toggle all files included in patch", "ViewPatchOptions": "커스텀 Patch 옵션 보기", "PatchOptionsTitle": "Patch 옵션", "EnterCommitFile": "Enter file to add selected lines to the patch (or toggle directory collapsed)", "EnterUpstream": "' '와 같은 형식으로 입력하세요.", "InvalidUpstream": "Upstream의 형식이 잘못되었습니다.' ' 와 같은 형식으로 입력하세요.", "ReturnToRemotesList": "원격목록으로 돌아가기", "NewRemote": "새로운 Remote 추가", "NewRemoteName": "새로운 Remote 이름:", "NewRemoteUrl": "새로운 Remote URL:", "EditRemoteName": "{{.remoteName}} 의 새로운 Remote 이름 입력:", "EditRemoteUrl": "{{.remoteName}} 의 새로운 Remote URL 입력:", "RemoveRemote": "Remote를 삭제", "DeleteRemoteBranch": "원격 브랜치를 삭제", "SetUpstream": "Set as upstream of checked-out branch", "EditRemoteTooltip": "Remote를 수정", "TagMenuTitle": "태그 작성", "TagNameTitle": "태그 이름", "TagMessageTitle": "태그 메시지", "PushTagTitle": "원격에 태그 '{{.tagName}}' 를 푸시", "PushTag": "태그를 push", "NewTag": "태그를 생성", "FetchRemoteTooltip": "원격을 업데이트", "FetchingRemoteStatus": "원격을 업데이트 중", "CheckoutCommit": "커밋을 체크아웃", "SureCheckoutThisCommit": "정말로 선택한 커밋을 체크아웃 하시겠습니까?", "GitFlowOptions": "Git-flow 옵션 보기", "NewBranchNamePrompt": "새로운 브랜치 이름 입력", "NextScreenMode": "다음 스크린 모드 (normal/half/fullscreen)", "PrevScreenMode": "이전 스크린 모드", "StartSearch": "검색 시작", "Panel": "패널", "Keybindings": "키 바인딩", "RenameBranch": "브랜치 이름 변경", "OpenKeybindingsMenu": "매뉴 열기", "ResetCherryPick": "Reset cherry-picked (copied) commits selection", "NextTab": "이전 탭", "PrevTab": "다음 탭", "CantUndoWhileRebasing": "리베이스중에는 되돌릴 수 없습니다.", "CantRedoWhileRebasing": "리베이스중에는 다시 실행할 수 없습니다.", "ConfirmationTitle": "확인 패널", "PrevPage": "이전 페이지", "NextPage": "다음 페이지", "GotoTop": "맨 위로 스크롤 ", "GotoBottom": "맨 아래로 스크롤 ", "ResetInParentheses": "(reset)", "OpenFilteringMenu": "View filter-by-path options", "ExitFilterMode": "Stop filtering by path", "MustExitFilterModePrompt": "Command not available in filtered mode. Exit filtered mode?", "EnterRefName": "Ref 입력:", "ExitDiffMode": "Diff 모드 종료", "DiffingMenuTitle": "Diff", "ViewDiffingOptions": "Diff 메뉴 열기", "OpenCommandLogMenu": "명령어 로그 메뉴 열기", "CommitDiff": "커밋의 iff", "CopyCommitHashToClipboard": "커밋 해시를 클립보드에 복사", "CommitHash": "커밋 해시", "CommitURL": "커밋 URL", "CopyCommitMessageToClipboard": "커밋 메시지를 클립보드에 복사", "CommitMessage": "커밋 메시지", "CommitAuthor": "커밋 작성자", "CopyCommitAttributeToClipboard": "커밋 attribute 복사", "CopyBranchNameToClipboard": "브랜치명을 클립보드에 복사", "CopyPathToClipboard": "파일명을 클립보드에 복사", "CopySelectedTextToClipboard": "선택한 텍스트를 클립보드에 복사", "NoFilesStagedTitle": "파일이 Staged 되지 않았습니다.", "NoFilesStagedPrompt": "파일이 Staged 되지 않았습니다. 모든 파일을 커밋하시겠습니까?", "BranchNotFoundTitle": "브랜치를 찾을 수 없습니다.", "BranchNotFoundPrompt": "브랜치를 찾을 수 없습니다. 새로운 브랜치를 생성합니다.", "DiscardChangeTitle": "선택한 라인을 unstaged", "DiscardChangePrompt": "정말로 선택한 라인을 삭제 (git reset) 하시겠습니까? 이 조작은 취소할 수 없습니다.\n이 경고를 비활성화 하려면 설정 파일의 'gui.skipDiscardChangeWarning' 를 true로 설정하세요.", "CreateNewBranchFromCommit": "커밋에서 새 브랜치를 만듭니다.", "ViewCommits": "커밋 보기", "RunningCustomCommandStatus": "커스텀 명령어 실행", "EnterSubmoduleTooltip": "서브모듈 열기", "CopySubmoduleNameToClipboard": "서브모듈 이름을 클립보드에 복사", "RemoveSubmodule": "서브모듈 삭제", "RemoveSubmodulePrompt": "정말로 서브모듈 '%s'및 해당 디렉토리를 제거하시겠습니까? 이것은 되돌릴 수 없습니다.", "ResettingSubmoduleStatus": "서브모듈를 리셋", "NewSubmoduleName": "새로운 서브모듈이름 :", "NewSubmoduleUrl": "새로운 서브모듈의 URL:", "NewSubmodulePath": "새로운 서브모듈의 경로", "NewSubmodule": "새로운 서브모듈 추가", "AddingSubmoduleStatus": "새로운 서브모듈 추가", "UpdateSubmoduleUrl": "서브모듈 '%s' 의 URL을 업데이트", "EditSubmoduleUrl": "서브모듈의 URL을 수정", "InitializingSubmoduleStatus": "서브모듈 초기화", "InitSubmoduleTooltip": "서브모듈 초기화", "SubmoduleUpdateTooltip": "서브모듈 업데이트", "UpdatingSubmoduleStatus": "서브모듈 업데이트", "BulkInitSubmodules": "서브모듈 일괄 초기화", "BulkUpdateSubmodules": "서브모듈 일괄 업데이트", "SubmodulesTitle": "서브모듈", "SuggestionsCheatsheetTitle": "추천", "SuggestionsTitle": "추천 (press %s to focus)", "ExtrasTitle": "명령어 로그", "PullRequestURLCopiedToClipboard": "풀 리퀘스트의 URL을 클립보드에 복사했습니다.", "CommitDiffCopiedToClipboard": "커밋의 Diff를 클립보드에 복사했습니다.", "CommitURLCopiedToClipboard": "커밋의 URL를 클립보드에 복사했습니다.", "CommitMessageCopiedToClipboard": "커밋 메시지를 클립보드에 복사했습니다.", "CommitAuthorCopiedToClipboard": "커밋 작성자를 클립보드에 복사했습니다.", "CopiedToClipboard": "클립보드에 복사했습니다.", "ErrCannotEditDirectory": "디렉토리는 편집할 수 없습니다.", "ErrStageDirWithInlineMergeConflicts": "병합 충돌이 발생한 파일을 포함하는 디렉토리는 Staged/untaged할 수 없습니다. 병합 충돌을 먼저 해결하세요.", "ErrRepositoryMovedOrDeleted": "저장소를 찾을 수 없습니다. 이미 삭제되었거나 이동되었을 가능성이 있습니다. ¯\\_(ツ)_/¯", "CommandLog": "명령어 로그", "ToggleShowCommandLog": "명령어 로그 표시 여부 전환", "FocusCommandLog": "명령어 로그에 포커스", "CommandLogHeader": "명령어 로그표시 여부는 '%s' 으로 전환할 수 있습니다.\n", "RandomTip": "랜덤 Tip", "SelectParentCommitForMerge": "병합을 위한 상위 커밋 선택", "ToggleWhitespaceInDiffView": "공백문자를 Diff 뷰에서 표시 여부 전환", "IncreaseContextInDiffView": "Diff 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기", "DecreaseContextInDiffView": "Diff 보기의 변경 사항 주위에 표시되는 컨텍스트 크기 줄이기", "CreatePullRequestOptions": "풀 리퀘스트 생성 옵션", "DefaultBranch": "기본 브랜치", "SelectBranch": "브랜치를 선택", "CreatePullRequest": "풀 리퀘스트 생성", "SelectConfigFile": "설정파일 선택", "NoConfigFileFoundErr": "설정 파일을 찾지 못했습니다.", "LoadingFileSuggestions": "파일 제안 로딩 중", "LoadingCommits": "커밋 로딩", "AbortTitle": "%s 중지", "AbortPrompt": "정말로 실행중인 %s 를 중지할까요?", "OpenLogMenu": "로그 메뉴 열기", "LogMenuTitle": "커밋 로그 옵션", "ShowGitGraph": "커밋 그래프 표시", "SortCommits": "커밋 정렬", "OpenCommitInBrowser": "브라우저에서 커밋 열기", "ViewBisectOptions": "Bisect 옵션 보기", "RewordInEditorTitle": "커밋 메시지를 에디터에서 수정", "ToggleRangeSelect": "드래그 선택 전환", "Actions": { "CheckoutCommit": "커밋 체크아웃", "CheckoutTag": "태그 체크아웃", "CheckoutBranch": "브랜치 체크아웃", "ForceCheckoutBranch": "브랜치 Force 체크아웃", "Merge": "병합", "RebaseBranch": "브랜치 리베이스", "RenameBranch": "브랜치 이름 변경", "CreateBranch": "브랜치 생성", "CherryPick": "(Cherry-pick) 커밋 붙여넣기", "CheckoutFile": "체크아웃 파일", "FixupCommit": "커밋 Fixup", "RewordCommit": "커밋 Reword", "DropCommit": "커밋 Drop", "EditCommit": "커밋 수정", "AmendCommit": "커밋 Amend", "ResetCommitAuthor": "커밋 작성자 Reset", "RevertCommit": "커밋 Revert", "CreateFixupCommit": "Fixup 커밋 생성", "CopyCommitMessageToClipboard": "커밋 메시지를 클립보드에 복사", "CopyCommitDiffToClipboard": "커밋 diff를 클립보드에 복사", "CopyCommitHashToClipboard": "커밋 해시를 클립보드에 복사", "CopyCommitURLToClipboard": "커밋 URL를 클립보드에 복사", "CopyCommitAuthorToClipboard": "커밋 작성자를 클립보드에 복사", "CopyCommitAttributeToClipboard": "클립보드에 복사", "DiscardAllChangesInFile": "Discard all changes in file", "DiscardAllUnstagedChangesInFile": "Discard all unstaged changes in file", "IgnoreExcludeFile": "Ignore file", "Commit": "커밋", "EditFile": "파일 수정", "Push": "푸시", "Pull": "업데이트(Pull)", "OpenFile": "파일 열기", "RemoveSubmodule": "서브모듈 삭제", "ResetSubmodule": "서브모듈 Reset", "AddSubmodule": "서브모듈 추가", "UpdateSubmoduleUrl": "서브모듈 URL 업데이트", "InitialiseSubmodule": "서브모듈 초기화", "UpdateSubmodule": "서브모듈 업데이트", "PushTag": "태그 푸시g", "DiscardUnstagedFileChanges": "Unstaged 파일 변경사항 버리기", "RemoveUntrackedFiles": "Untracked 파일 삭제", "RemoveStagedFiles": "Staged 파일 삭제", "Undo": "되돌리기", "Redo": "다시 실행", "CopyPullRequestURL": "풀 리퀘스트 URL 복사", "OpenMergeTool": "병합 도구 열기", "OpenCommitInBrowser": "브라우저에서 커밋 열기", "OpenPullRequest": "브라우저에서 풀 리퀘스트 열기" }, "Bisect": { "ResetTitle": "'git bisect' 를 리셋", "ResetPrompt": "정말로 'git bisect' 를 리셋하시겠습니까?", "ResetOption": "Bisect를 리셋", "Mark": "Mark %s as %s", "SkipCurrent": "%s 를 스킵", "CompleteTitle": "Bisect 완료" }, "Log": {}, "BreakingChangesByVersion": {} } lazygit-0.50.0+ds1/pkg/i18n/translations/nl.json000066400000000000000000000372111500612110400213210ustar00rootroot00000000000000{ "NotEnoughSpace": "Niet genoeg ruimte om de panelen te renderen", "FilesTitle": "Bestanden", "UnstagedChanges": "Unstaged wijzigingen", "StagedChanges": "Staged wijzigingen", "MainTitle": "Hoofd", "StagingTitle": "Staging", "MergingTitle": "Mergen", "NormalTitle": "Normaal", "CommitSummary": "Commitbericht", "CredentialsUsername": "Gebruikersnaam", "CredentialsPassword": "Wachtwoord", "CredentialsPassphrase": "Voer een wachtwoordzin in voor de SSH-sleutel", "PassUnameWrong": "Wachtwoord en/of gebruikersnaam verkeerd", "Commit": "Commit veranderingen", "AmendLastCommit": "Wijzig laatste commit", "AmendLastCommitTitle": "Wijzig laatste commit", "SureToAmend": "Weet je zeker dat je de laatste commit wilt wijzigen? U kunt het commit-bericht wijzigen vanuit het commits-paneel.", "NoCommitToAmend": "Er is geen commits om te wijzigen.", "CommitChangesWithEditor": "Commit veranderingen met de git editor", "GlobalTitle": "Globale sneltoetsen", "Execute": "Uitvoeren", "Stage": "Toggle staged", "ToggleStagedAll": "Toggle staged alle", "ToggleTreeView": "Toggle bestandsboom weergave", "Refresh": "Verversen", "MergeConflictsTitle": "Merge conflicten", "Checkout": "Uitchecken", "NoChangedFiles": "Geen veranderde bestanden", "SoftReset": "Zacht reset", "AlreadyCheckedOutBranch": "Je hebt deze branch al uitgecheckt", "SureForceCheckout": "Weet je zeker dat je het uitchecken wil forceren? Al je lokale verandering zullen worden verwijdert", "ForceCheckoutBranch": "Forceer uitchecken op deze branch", "BranchName": "Branch naam", "NewBranchNameBranchOff": "Nieuw branch naam (Branch is afgeleid van '{{.branchName}}')", "CantDeleteCheckOutBranch": "Je kan een uitgecheckte branch niet verwijderen!", "ForceDeleteBranchMessage": "Weet je zeker dat je branch '{{.selectedBranchName}}' geforceerd wil verwijderen?", "RebaseBranch": "Rebase branch", "CantRebaseOntoSelf": "Je kan niet een branch rebasen op zichzelf", "CantMergeBranchIntoItself": "Je kan niet een branch in zichzelf mergen", "ForceCheckout": "Forceer checkout", "CheckoutByName": "Uitchecken bij naam", "NewBranch": "Nieuwe branch", "NoBranchesThisRepo": "Geen branches voor deze repo", "CommitWithoutMessageErr": "Je kan geen commit maken zonder commit bericht", "Close": "Sluiten", "CloseCancel": "Sluiten", "Confirm": "Bevestig", "SureFixupThisCommit": "Weet je zeker dat je fixup wil uitvoeren op deze commit? De commit hieronder zol worden squashed in deze", "SureSquashThisCommit": "Weet je zeker dat je deze commit wil samenvoegen met de commit hieronder?", "PickCommitTooltip": "Kies commit (wanneer midden in rebase)", "RevertCommit": "Commit ongedaan maken", "Reword": "Hernoem commit", "DropCommit": "Verwijder commit", "MoveDownCommit": "Verplaats commit 1 naar beneden", "MoveUpCommit": "Verplaats commit 1 naar boven", "EditCommitTooltip": "Wijzig commit", "AmendCommitTooltip": "Wijzig commit met staged veranderingen", "RewordCommitEditor": "Hernoem commit met editor", "NoCommitsThisBranch": "Geen commits in deze branch", "Error": "Foutmelding", "Undo": "Ongedaan maken", "UndoReflog": "Ongedaan maken (via reflog) (experimenteel)", "RedoReflog": "Redo (via reflog) (experimenteel)", "Drop": "Laten vallen", "Apply": "Toepassen", "NoStashEntries": "Geen stash items", "StashDrop": "Stash laten vallen", "SurePopStashEntry": "Weet je zeker dat je deze stash entry wil poppen?", "StashApply": "Stash toepassen", "SureApplyStashEntry": "Weet je zeker dat je deze stash entry wil toepassen?", "NoTrackedStagedFilesStash": "Je hebt geen tracked/staged bestanden om te laten stashen", "StashChanges": "Stash veranderingen", "OpenConfig": "Open config bestand", "EditConfig": "Verander config bestand", "ForcePush": "Forceer push", "ForcePushPrompt": "Jouw branch is afgeweken van de remote branch. Druk 'esc' om te annuleren, of 'enter' om geforceert te pushen.", "CheckForUpdate": "Check voor updates", "CheckingForUpdates": "Zoeken naar updates...", "OnLatestVersionErr": "Je hebt al de laatste versie", "MajorVersionErr": "Nieuwe versie ({{.newVersion}}) is niet backwards compatibele vergeleken met de huidige versie ({{.currentVersion}})", "CouldNotFindBinaryErr": "Kon geen binary vinden op {{.url}}", "IntroPopupMessage": "Bedankt voor het gebruik maken van lazygit! 2 dingen die je moet weten:\n\n1) Als je meer van lazygit zijn features wilt leren bekijk dan deze video:\n https://youtu.be/CPLdltN7wgE\n\n2) Als je git gebruikt, ben je een programmeur! Met jouw hulp kunnen we lazygit verbeteren, dus overweeg om een ​​donateur te worden en mee te doen aan het plezier op\n https://github.com/jesseduffield/lazygit", "GitconfigParseErr": "Gogit kon je gitconfig bestand niet goed parsen door de aanwezigheid van losstaande '\\' tekens. Het weghalen van deze tekens zou het probleem moeten oplossen. ", "EditFile": "Verander bestand", "OpenFile": "Open bestand", "IgnoreFile": "Voeg toe aan .gitignore", "RefreshFiles": "Refresh bestanden", "Merge": "Merge in met huidige checked out branch", "ConfirmQuit": "Weet je zeker dat je dit programma wil sluiten?", "SwitchRepo": "Wissel naar een recente repo", "UnsupportedGitService": "Niet-ondersteunde git-service", "CopyPullRequestURL": "Kopieer de URL van het pull-verzoek naar het klembord", "NoBranchOnRemote": "Deze branch bestaat niet op de remote. U moet het eerst naar de remote pushen.", "FileEnter": "Stage individuele hunks/lijnen", "FileStagingRequirements": "Kan alleen individuele lijnen stagen van getrackte bestanden met onstaged veranderingen", "StageSelectionTooltip": "Toggle lijnen staged / unstaged", "DiscardSelection": "Verwijdert change (git reset)", "ToggleSelectHunk": "Toggle selecteer hunk", "ToggleSelectionForPatch": "Voeg toe/verwijder lijn(en) in patch", "ToggleStagingView": "Ga naar een ander paneel", "ReturnToFilesPanel": "Ga terug naar het bestanden paneel", "FastForward": "Fast-forward deze branch vanaf zijn upstream", "FoundConflictsTitle": "Conflicten!", "PickHunk": "Kies stuk", "PickAllHunks": "Kies beide stukken", "ViewMergeRebaseOptions": "Bekijk merge/rebase opties", "NotMergingOrRebasing": "Je bent momenteel niet aan het rebasen of mergen", "RecentRepos": "Recente repositories", "MergeOptionsTitle": "Merge opties", "RebaseOptionsTitle": "Rebase opties", "CommitSummaryTitle": "Commit bericht", "LocalBranchesTitle": "Branches", "SearchTitle": "Zoek", "PatchBuildingTitle": "Patch bouwen", "InformationTitle": "Informatie", "ConflictsResolved": "Alle merge conflicten zijn opgelost. Wilt je verder gaan?", "FwdNoUpstream": "Kan niet de branch vooruitspoelen zonder upstream", "FwdCommitsToPush": "Je kan niet vooruitspoelen als de branch geen nieuwe commits heeft", "ErrorOccurred": "Er is iets fout gegaan! Zou je hier een issue aan willen maken", "NoRoom": "Niet genoeg ruimte", "YouAreHere": "JE BENT HIER", "RewordNotSupported": "Herformatteren van commits in interactief rebasen is nog niet ondersteund", "CherryPickCopy": "Kopieer commit (cherry-pick)", "PasteCommits": "Plak commits (cherry-pick)", "CherryPick": "Cherry-Pick", "Donate": "Doneer", "PrevLine": "Selecteer de vorige lijn", "NextLine": "Selecteer de volgende lijn", "PrevHunk": "Selecteer de vorige hunk", "NextHunk": "Selecteer de volgende hunk", "PrevConflict": "Selecteer voorgaand conflict", "NextConflict": "Selecteer volgende conflict", "SelectPrevHunk": "Selecteer bovenste hunk", "SelectNextHunk": "Selecteer onderste hunk", "ScrollDown": "Scroll omlaag", "ScrollUp": "Scroll omhoog", "ScrollUpMainWindow": "Scroll naar beneden vanaf hoofdpaneel", "ScrollDownMainWindow": "Scroll naar beneden vanaf hoofdpaneel", "AmendCommitTitle": "Commit wijzigen", "AmendCommitPrompt": "Weet je zeker dat je deze commit wil wijzigen met de vorige staged bestanden?", "DropCommitTitle": "Verwijder commit", "DropCommitPrompt": "Weet je zeker dat je deze commit wil verwijderen?", "PullingStatus": "Pullen", "PushingStatus": "Pushen", "FetchingStatus": "Fetchen", "SquashingStatus": "Squashen", "DeletingStatus": "Verwijderen", "MovingStatus": "Verplaatsen", "RebasingStatus": "Rebasen", "AmendingStatus": "Wijzigen", "CherryPickingStatus": "Cherry-picken", "UndoingStatus": "Ongedaan maken", "CheckingOutStatus": "Uitchecken", "CommitFiles": "Commit bestanden", "ViewItemFiles": "Bekijk gecommite bestanden", "CommitFilesTitle": "Commit bestanden", "CheckoutCommitFileTooltip": "Bestand uitchecken", "DiscardOldFileChangeTooltip": "Uitsluit deze commit zijn veranderingen aan dit bestand", "DiscardFileChangesTitle": "Uitsluit bestand zijn veranderingen", "DiscardFileChangesPrompt": "Weet je zeker dat je de wijzigingen van deze commit in dit bestand wilt weggooien? Als dit bestand is gecreëerd in deze commit dan zal dit bestand worden verwijdert", "CreateRepo": "Niet in een git repository. Creëer een nieuwe git repository? (y/n): ", "AutoStashPrompt": "Je moet je veranderingen stashen en poppen om ze over te brengen. Dit automatisch doen? (enter/esc)", "StashPrefix": "Auto-stashing veranderingen voor ", "Discard": "Bekijk 'veranderingen ongedaan maken' opties", "Cancel": "Annuleren", "DiscardAllChanges": "Negeer alle wijzigingen", "DiscardUnstagedChanges": "Negeer unstaged wijzigingen", "DiscardAllChangesToAllFiles": "Verwijder werkende tree", "DiscardAnyUnstagedChanges": "Gooi unstaged wijzigingen weg", "DiscardUntrackedFiles": "Negeer niet-gevonden bestanden", "HardReset": "Harde reset", "ViewResetOptions": "Bekijk reset opties", "CreateFixupCommit": "Creëer fixup commit", "CreateFixupCommitTooltip": "Creëer fixup commit", "SquashAboveCommitsTooltip": "Squash bovenstaande commits", "CommitChangesWithoutHook": "Commit veranderingen zonder pre-commit hook", "ResetTo": "Reset naar", "PressEnterToReturn": "Press om terug te gaan naar lazygit", "ViewStashOptions": "Bekijk stash opties", "StashAllChanges": "Stash-bestanden", "StashAllChangesKeepIndex": "Stash staged wijzigingen", "StashOptions": "Stash opties", "NotARepository": "Fout: moet in een git repository uitgevoerd worden", "Jump": "Ga naar paneel", "DiscardPatch": "Patch weg gooien", "DiscardPatchConfirm": "Je kan alleen maar een patch bouwen van 1 commit. Huidige patch weggooien?", "CantPatchWhileRebasingError": "Je kan geen patch bouwen of patch commando uitvoeren wanneer je in een merging of rebasing state zit", "ToggleAddToPatch": "Toggle bestand inbegrepen in patch", "ViewPatchOptions": "Bekijk aangepaste patch opties", "PatchOptionsTitle": "Patch opties", "NoPatchError": "Nog geen patch gecreëerd. Om een patch te bouwen gebruik 'space' op een commit bestand of 'enter' om een spesiefieke lijnen toe te voegen", "EnterCommitFile": "Enter bestand om geselecteerde regels toe te voegen aan de patch", "ExitCustomPatchBuilder": "Sluit lijn-bij-lijn modus", "EnterUpstream": "Enter upstream als ' '", "ReturnToRemotesList": "Ga terug naar remotes lijst", "NewRemote": "Voeg een nieuwe remote toe", "NewRemoteName": "Nieuwe remote name:", "NewRemoteUrl": "Nieuwe remote url:", "EditRemoteName": "Enter updated remote naam voor {{.remoteName}}:", "EditRemoteUrl": "Enter updated remote url voor {{.remoteName}}:", "RemoveRemote": "Verwijder remote", "DeleteRemoteBranch": "Verwijder remote branch", "SetAsUpstreamTooltip": "Stel in als upstream van uitgecheckte branch", "SetUpstream": "Stel in als upstream van uitgecheckte branch", "SetUpstreamTitle": "Stel in als upstream branch", "EditRemoteTooltip": "Wijzig remote", "TagNameTitle": "Tag naam:", "PushTagTitle": "Remote om tag '{{.tagName}}' te pushen naar:", "NewTag": "Creëer tag", "FetchRemoteTooltip": "Fetch remote", "FetchingRemoteStatus": "Remote fetchen", "SureCheckoutThisCommit": "Weet je zeker dat je deze commit wil uitchecken?", "GitFlowOptions": "Laat git-flow opties zien", "NotAGitFlowBranch": "Dit lijkt geen git flow branch te zijn", "NewBranchNamePrompt": "Noem een nieuwe branch naam", "IgnoreTracked": "Negeer tracked bestand", "IgnoreTrackedPrompt": "Weet je zeker dat je een getracked bestand wil negeren?", "ViewResetToUpstreamOptions": "Bekijk upstream reset opties", "NextScreenMode": "Volgende scherm modus (normaal/half/groot)", "PrevScreenMode": "Vorige scherm modus", "StartSearch": "Start met zoeken", "Panel": "Paneel", "Keybindings": "Sneltoetsen", "RenameBranch": "Hernoem branch", "NewGitFlowBranchPrompt": "Nieuwe '{{.branchType}}' naam:", "RenameBranchWarning": "Deze branch volgt een remote. Deze actie zal alleen de locale branch name wijzigen niet de naam van de remote branch. Verder gaan?", "OpenKeybindingsMenu": "Open menu", "ResetCherryPick": "Reset cherry-picked (gekopieerde) commits selectie", "NextTab": "Volgende tabblad", "PrevTab": "Vorige tabblad", "CantUndoWhileRebasing": "Kan niet ongedaan maken terwijl je aan het rebasen bent", "CantRedoWhileRebasing": "Kan niet opnieuw doen (redo) terwijl je aan het rebasen bent", "MustStashWarning": "Een patch in de index stoppen vereist stashen en onstashen van je wijzigingen. Als er iets verkeert gaat kan je je bestanden terug vinden in de stash. Verder gaan?", "MustStashTitle": "Moet stashen", "ConfirmationTitle": "Bevestigingspaneel", "PrevPage": "Vorige pagina", "NextPage": "Volgende pagina", "GotoTop": "Scroll naar boven", "GotoBottom": "Scroll naar beneden", "FilteringBy": "Filteren bij", "ResetInParentheses": "(reset)", "OpenFilteringMenu": "Bekijk scoping opties", "FilterBy": "Filter bij", "ExitFilterMode": "Stop met filteren bij pad", "FilterPathOption": "Vulin pad om op te filteren", "EnterFileName": "Vulin path:", "FilteringMenuTitle": "Filteren", "MustExitFilterModeTitle": "Command niet beschikbaar", "MustExitFilterModePrompt": "Command niet beschikbaar in filter modus. Sluit filter modus?", "EnterRefToDiff": "Vul in ref naar diff", "EnterRefName": "Vul in ref:", "ExitDiffMode": "Sluit diff mode", "DiffingMenuTitle": "Diffen", "SwapDiff": "Keer diff richting om", "ViewDiffingOptions": "Open diff menu", "ShowingGitDiff": "Laat output zien voor:", "CopyCommitHashToClipboard": "Kopieer commit hash naar klembord", "CopyCommitMessageToClipboard": "Kopieer commit bericht naar klembord", "CopyBranchNameToClipboard": "Kopieer branch name naar klembord", "CopyPathToClipboard": "Kopieer de bestandsnaam naar het klembord", "CommitPrefixPatternError": "Fout in commitPrefix patroon", "NoFilesStagedTitle": "Geen bestanden gestaged", "NoFilesStagedPrompt": "Je hebt geen bestanden gestaged. Commit alle bestanden?", "BranchNotFoundTitle": "Branch niet gevonden", "BranchNotFoundPrompt": "Branch niet gevonden. Creëer een nieuwe branch genaamd", "CreateNewBranchFromCommit": "Creëer nieuwe branch van commit", "ViewCommits": "Bekijk commits", "EnterSubmoduleTooltip": "Enter submodule", "CopySubmoduleNameToClipboard": "Kopieer submodule naam naar klembord", "NewSubmodule": "Voeg nieuwe submodule toe", "InitSubmoduleTooltip": "Initialiseer submodule", "ViewBulkSubmoduleOptions": "Bekijk bulk submodule opties", "NavigationTitle": "Lijstpaneel navigatie", "PullRequestURLCopiedToClipboard": "Pull-aanvraag-URL gekopieerd naar klembord", "CommitMessageCopiedToClipboard": "Commit message gekopieerd naar klembord", "CopiedToClipboard": "gekopieerd naar klembord", "CreatePullRequestOptions": "Bekijk opties voor pull-aanvraag", "CreatePullRequest": "Maak een pull-request", "ConfirmRevertCommit": "Weet u zeker dat u {{.selectedCommit}} ongedaan wilt maken?", "ToggleRangeSelect": "Toggle drag selecteer", "Actions": {}, "Bisect": {}, "Log": {}, "BreakingChangesByVersion": {} } lazygit-0.50.0+ds1/pkg/i18n/translations/pl.json000066400000000000000000001714051500612110400213270ustar00rootroot00000000000000{ "NotEnoughSpace": "Za mało miejsca na wyświetlenie paneli", "DiffTitle": "Różnice", "FilesTitle": "Pliki", "BranchesTitle": "Gałęzie", "CommitsTitle": "Commity", "StashTitle": "Schowek", "EasterEgg": "Jajko wielkanocne", "UnstagedChanges": "Zmiany niezatwierdzone", "StagedChanges": "Zmiany zatwierdzone", "MainTitle": "Główny", "StagingTitle": "Panel główny (zatwierdzanie)", "MergingTitle": "Panel główny (scalanie)", "NormalTitle": "Panel główny (normalny)", "LogTitle": "Dziennik", "CommitSummary": "Podsumowanie commita", "CredentialsUsername": "Nazwa użytkownika", "CredentialsPassword": "Hasło", "CredentialsPassphrase": "Wprowadź hasło do klucza SSH", "CredentialsPIN": "Wprowadź PIN do klucza SSH", "PassUnameWrong": "Niewłaściwe hasło, fraza lub nazwa użytkownika", "CommitTooltip": "Zatwierdź zmiany zatwierdzone.", "AmendLastCommit": "Popraw ostatni commit", "AmendLastCommitTitle": "Popraw ostatni commit", "SureToAmend": "Czy na pewno chcesz poprawić ostatni commit? Następnie możesz zmienić wiadomość commita z panelu commitów.", "NoCommitToAmend": "Brak commita do poprawienia.", "CommitChangesWithEditor": "Zatwierdź zmiany używając edytora git", "FindBaseCommitForFixup": "Znajdź bazowy commit do poprawki", "FindBaseCommitForFixupTooltip": "Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: ", "NoBaseCommitsFound": "Nie znaleziono bazowych commitów", "MultipleBaseCommitsFoundStaged": "Znaleziono wiele bazowych commitów. (Spróbuj zatwierdzić mniej zmian naraz)", "MultipleBaseCommitsFoundUnstaged": "Znaleziono wiele bazowych commitów. (Spróbuj zatwierdzić część zmian)", "BaseCommitIsAlreadyOnMainBranch": "Bazowy commit dla tej zmiany jest już na gałęzi głównej", "BaseCommitIsNotInCurrentView": "Bazowy commit nie jest w bieżącym widoku", "HunksWithOnlyAddedLinesWarning": "Istnieją zakresy tylko z dodanymi liniami w różnicach; uważaj, aby sprawdzić, czy te należą do znalezionego bazowego commita.\n\nKontynuować?", "GlobalTitle": "Globalne skróty klawiszowe", "Execute": "Wykonaj", "Stage": "Zatwierdź", "StageTooltip": "Przełącz zatwierdzenie dla wybranego pliku.", "ToggleStagedAll": "Zatwierdź wszystko", "ToggleStagedAllTooltip": "Przełącz zatwierdzenie/odznaczenie dla wszystkich plików w drzewie roboczym.", "ToggleTreeView": "Przełącz widok drzewa plików", "ToggleTreeViewTooltip": "Przełącz widok plików między płaskim a drzewem. Płaski układ pokazuje wszystkie ścieżki plików na jednej liście, układ drzewa grupuje pliki według katalogów.", "OpenDiffTool": "Otwórz zewnętrzne narzędzie różnic (git difftool)", "OpenMergeTool": "Otwórz zewnętrzne narzędzie scalania", "OpenMergeToolTooltip": "Uruchom `git mergetool`.", "Refresh": "Odśwież", "RefreshTooltip": "Odśwież stan git (tj. uruchom `git status`, `git branch`, itp. w tle, aby zaktualizować zawartość paneli). To nie uruchamia `git fetch`.", "Push": "Wypchnij", "Pull": "Pociągnij", "PushTooltip": "Wypchnij bieżącą gałąź do jej gałęzi nadrzędnej. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej.", "PullTooltip": "Pociągnij zmiany z zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej.", "Scroll": "Przewiń", "FileFilter": "Filtruj pliki według statusu", "CopyToClipboardMenu": "Kopiuj do schowka", "CopyFileName": "Nazwa pliku", "CopyFileDiffTooltip": "Jeśli istnieją zatwierdzone elementy, ta komenda bierze pod uwagę tylko je. W przeciwnym razie bierze pod uwagę wszystkie niezatwierdzone.", "CopySelectedDiff": "Różnice wybranego pliku", "CopyAllFilesDiff": "Różnice wszystkich plików", "NoContentToCopyError": "Nic do skopiowania", "FileNameCopiedToast": "Nazwa pliku skopiowana do schowka", "FilePathCopiedToast": "Ścieżka pliku skopiowana do schowka", "FileDiffCopiedToast": "Różnice pliku skopiowane do schowka", "AllFilesDiffCopiedToast": "Różnice wszystkich plików skopiowane do schowka", "FilterStagedFiles": "Pokaż tylko zatwierdzone pliki", "FilterUnstagedFiles": "Pokaż tylko niezatwierdzone pliki", "MergeConflictsTitle": "Konflikty scalania", "Checkout": "Przełącz", "CheckoutTooltip": "Przełącz wybrany element.", "CantCheckoutBranchWhilePulling": "Nie możesz przełączyć na inną gałąź podczas pobierania bieżącej gałęzi", "TagCheckoutTooltip": "Przełącz wybrany tag jako odłączoną głowę (detached HEAD).", "RemoteBranchCheckoutTooltip": "Przełącz na nową lokalną gałąź na podstawie wybranej gałęzi zdalnej. Nowa gałąź będzie śledzić gałąź zdalną.", "CantPullOrPushSameBranchTwice": "Nie możesz wypchnąć lub pociągnąć gałęzi, podczas gdy jest już wypychana lub pociągana", "NoChangedFiles": "Brak zmienionych plików", "SoftReset": "Miękki reset", "AlreadyCheckedOutBranch": "Już przełączono na tę gałąź", "SureForceCheckout": "Czy na pewno chcesz wymusić przełączenie? Stracisz wszystkie lokalne zmiany", "ForceCheckoutBranch": "Wymuś przełączenie gałęzi", "BranchName": "Nazwa gałęzi", "NewBranchNameBranchOff": "Nowa nazwa gałęzi (gałąź oparta na '{{.branchName}}')", "CantDeleteCheckOutBranch": "Nie możesz usunąć przełączonej gałęzi!", "DeleteBranchTitle": "Usuń gałąź '{{.selectedBranchName}}'?", "DeleteLocalBranch": "Usuń lokalną gałąź", "DeleteRemoteBranchOption": "Usuń gałąź zdalną", "DeleteRemoteBranchPrompt": "Czy na pewno chcesz usunąć gałąź zdalną '{{.selectedBranchName}}' z '{{.upstream}}'?", "ForceDeleteBranchTitle": "Wymuś usunięcie gałęzi", "ForceDeleteBranchMessage": "'{{.selectedBranchName}}' nie jest w pełni scalona. Czy na pewno chcesz ją usunąć?", "RebaseBranch": "Przebazuj", "RebaseBranchTooltip": "Przebazuj przełączoną gałąź na wybraną gałąź.", "CantRebaseOntoSelf": "Nie możesz przebazować gałęzi na siebie", "CantMergeBranchIntoItself": "Nie możesz scalić gałęzi do siebie", "ForceCheckout": "Wymuś przełączenie", "ForceCheckoutTooltip": "Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź.", "CheckoutByName": "Przełącz według nazwy", "CheckoutByNameTooltip": "Przełącz według nazwy. W polu wprowadzania możesz wpisać '-' aby przełączyć się na ostatnią gałąź.", "NewBranch": "Nowa gałąź", "NewBranchFromStashTooltip": "Utwórz nową gałąź z wybranego wpisu schowka. Działa poprzez przełączenie git na commit, na którym wpis schowka został utworzony, tworzenie nowej gałęzi z tego commita, a następnie zastosowanie wpisu schowka do nowej gałęzi jako dodatkowego commita.", "NoBranchesThisRepo": "Brak gałęzi dla tego repozytorium", "CommitWithoutMessageErr": "Nie możesz commitować bez wiadomości commita", "Close": "Zamknij", "CloseCancel": "Zamknij/Anuluj", "Confirm": "Potwierdź", "Quit": "Wyjdź", "SquashTooltip": "Scal wybrany commit z commitami poniżej. Wiadomość wybranego commita zostanie dołączona do commita poniżej.", "CannotSquashOrFixupFirstCommit": "Nie ma commita poniżej do scalenia", "Fixup": "Poprawka", "FixupTooltip": "Włącz wybrany commit do commita poniżej. Podobnie do fixup, ale wiadomość wybranego commita zostanie odrzucona.", "SureFixupThisCommit": "Czy na pewno chcesz 'poprawić' wybrane commit(y) do commita poniżej?", "SureSquashThisCommit": "Czy na pewno chcesz scalić wybrane commit(y) do commita poniżej?", "Squash": "Scal", "PickCommitTooltip": "Oznacz wybrany commit do wybrania (podczas rebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji rebazowania.", "Pick": "Wybierz", "CantPickDisabledReason": "Nie możesz wybrać commita podczas rebazowania", "Edit": "Edytuj", "RevertCommit": "Cofnij commit", "Revert": "Cofnij", "RevertCommitTooltip": "Utwórz commit cofający dla wybranego commita, który stosuje zmiany wybranego commita w odwrotnej kolejności.", "Reword": "Przeformułuj", "CommitRewordTooltip": "Przeformułuj wiadomość wybranego commita.", "DropCommit": "Usuń", "DropCommitTooltip": "Usuń wybrany commit. To usunie commit z gałęzi za pomocą rebazowania. Jeśli commit wprowadza zmiany, od których zależą późniejsze commity, być może będziesz musiał rozwiązać konflikty scalania.", "MoveDownCommit": "Przesuń commit w dół", "MoveUpCommit": "Przesuń commit w górę", "CannotMoveAnyFurther": "Nie można przesunąć dalej", "EditCommit": "Edytuj (rozpocznij interaktywne rebazowanie)", "EditCommitTooltip": "Edytuj wybrany commit. Użyj tego, aby rozpocząć interaktywne rebazowanie od wybranego commita. Podczas trwania rebazowania, to oznaczy wybrany commit do edycji, co oznacza, że po kontynuacji rebazowania, rebazowanie zostanie wstrzymane na wybranym commicie, aby umożliwić wprowadzenie zmian.", "AmendCommitTooltip": "Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą rebazowania.", "Amend": "Popraw", "ResetAuthor": "Resetuj autora", "ResetAuthorTooltip": "Resetuj autora commita do aktualnie skonfigurowanego użytkownika. To również odświeży znacznik czasu autora", "SetAuthor": "Ustaw autora", "SetAuthorTooltip": "Ustaw autora na podstawie monitu", "AddCoAuthor": "Dodaj współautora", "AmendCommitAttribute": "Popraw atrybut commita", "AmendCommitAttributeTooltip": "Ustaw/Resetuj autora commita lub ustaw współautora.", "SetAuthorPromptTitle": "Ustaw autora (musi wyglądać jak 'Imię ')", "AddCoAuthorPromptTitle": "Dodaj współautora (musi wyglądać jak 'Imię ')", "AddCoAuthorTooltip": "Dodaj współautora używając metadanych Github/Gitlab Co-authored-by.", "SureResetCommitAuthor": "Pole autora tego commita zostanie zaktualizowane, aby pasowało do skonfigurowanego użytkownika. To również odświeży znacznik czasu autora. Kontynuować?", "RewordCommitEditor": "Przeformułuj za pomocą edytora", "NoCommitsThisBranch": "Brak commitów dla tej gałęzi", "UpdateRefHere": "Zaktualizuj gałąź '{{.ref}}' tutaj", "Error": "Błąd", "Undo": "Cofnij", "UndoReflog": "Cofnij", "RedoReflog": "Ponów", "UndoTooltip": "Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby cofnąć ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity.", "RedoTooltip": "Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby ponowić ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity.", "UndoMergeResolveTooltip": "Cofnij ostatnie rozwiązanie konfliktu scalania.", "DiscardAllTooltip": "Odrzuć wszystkie zmiany (zarówno zatwierdzone jak i niezatwierdzone) w '{{.path}}'.", "DiscardUnstagedTooltip": "Odrzuć niezatwierdzone zmiany w '{{.path}}'.", "Pop": "Wyciągnij", "StashPopTooltip": "Zastosuj wpis schowka do katalogu roboczego i usuń wpis schowka.", "Drop": "Usuń", "StashDropTooltip": "Usuń wpis schowka z listy schowka.", "Apply": "Zastosuj", "StashApplyTooltip": "Zastosuj wpis schowka do katalogu roboczego.", "NoStashEntries": "Brak wpisów schowka", "StashDrop": "Usuń schowek", "StashPop": "Wyciągnij schowek", "SurePopStashEntry": "Czy na pewno chcesz wyciągnąć ten wpis schowka?", "StashApply": "Zastosuj schowek", "SureApplyStashEntry": "Czy na pewno chcesz zastosować ten wpis schowka?", "NoTrackedStagedFilesStash": "Nie masz śledzonych/zatwierdzonych plików do schowania", "NoFilesToStash": "Nie masz plików do schowania", "StashChanges": "Schowaj zmiany", "RenameStash": "Zmień nazwę schowka", "RenameStashPrompt": "Zmień nazwę schowka: {{.stashName}}", "OpenConfig": "Otwórz plik konfiguracyjny", "EditConfig": "Edytuj plik konfiguracyjny", "ForcePush": "Wymuś wysłanie", "ForcePushPrompt": "Twoja gałąź rozbiegła się z gałęzią zdalną. Naciśnij {{.cancelKey}}, aby anulować, lub {{.confirmKey}}, aby wymusić wysłanie.", "ForcePushDisabled": "Twoja gałąź rozbiegła się z gałęzią zdalną i masz wyłączone wymuszanie wysyłania", "UpdatesRejectedAndForcePushDisabled": "Aktualizacje zostały odrzucone i wyłączyłeś wymuszenie wysłania", "CheckForUpdate": "Sprawdź aktualizacje", "CheckingForUpdates": "Sprawdzanie aktualizacji...", "UpdateAvailableTitle": "Dostępna aktualizacja!", "UpdateAvailable": "Pobrać i zainstalować wersję {{.newVersion}}?", "UpdateInProgressWaitingStatus": "Aktualizacja", "UpdateCompletedTitle": "Aktualizacja zakończona!", "UpdateCompleted": "Aktualizacja została pomyślnie zainstalowana. Uruchom ponownie lazygit, aby zaczęła działać.", "FailedToRetrieveLatestVersionErr": "Nie udało się pobrać informacji o wersji", "OnLatestVersionErr": "Masz już najnowszą wersję", "MajorVersionErr": "Nowa wersja ({{.newVersion}}) zawiera zmiany niekompatybilne wstecznie w porównaniu z bieżącą wersją ({{.currentVersion}})", "CouldNotFindBinaryErr": "Nie można znaleźć żadnego pliku binarnego pod adresem {{.url}}", "UpdateFailedErr": "Aktualizacja nie powiodła się: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "Aktualizacja w toku", "ConfirmQuitDuringUpdate": "Aktualizacja jest w toku. Czy na pewno chcesz wyjść?", "MergeToolTitle": "Narzędzie scalania", "MergeToolPrompt": "Czy na pewno chcesz otworzyć `git mergetool`?", "GitconfigParseErr": "Gogit nie mógł przetworzyć pliku gitconfig z powodu obecności niezacytowanych znaków '\\'. Usunięcie ich powinno rozwiązać problem.", "EditFile": "Edytuj plik", "EditFileTooltip": "Otwórz plik w zewnętrznym edytorze.", "OpenFile": "Otwórz plik", "OpenFileTooltip": "Otwórz plik w domyślnej aplikacji.", "OpenInEditor": "Otwórz w edytorze", "IgnoreFile": "Dodaj do .gitignore", "ExcludeFile": "Dodaj do .git/info/exclude", "RefreshFiles": "Odśwież pliki", "Merge": "Scal", "MergeBranchTooltip": "Scal wybraną gałąź z aktualnie sprawdzoną gałęzią.", "ConfirmQuit": "Czy na pewno chcesz wyjść?", "SwitchRepo": "Przełącz na ostatnie repozytorium", "UnsupportedGitService": "Nieobsługiwana usługa git", "CopyPullRequestURL": "Kopiuj adres URL żądania ściągnięcia do schowka", "NoBranchOnRemote": "Ta gałąź nie istnieje na zdalnym serwerze. Musisz ją najpierw wysłać na zdalny serwer.", "Fetch": "Pobierz", "FetchTooltip": "Pobierz zmiany ze zdalnego serwera.", "FileEnter": "Zatwierdź linie / Zwiń katalog", "FileEnterTooltip": "Jeśli wybrany element jest plikiem, skup się na widoku zatwierdzania, aby móc zatwierdzać poszczególne fragmenty/linie. Jeśli wybrany element jest katalogiem, zwiń/rozwiń go.", "FileStagingRequirements": "Można zatwierdzać poszczególne linie tylko dla śledzonych plików", "StageSelectionTooltip": "Przełącz zaznaczenie zatwierdzone/niezatwierdzone.", "DiscardSelection": "Odrzuć", "DiscardSelectionTooltip": "Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie.", "ToggleSelectHunk": "Zaznacz fragment", "ToggleSelectHunkTooltip": "Przełącz tryb zaznaczania fragmentu.", "ToggleSelectionForPatch": "Przełącz linie w łatce", "EditHunk": "Edytuj fragment", "EditHunkTooltip": "Edytuj wybrany fragment w zewnętrznym edytorze.", "ToggleStagingView": "Przełącz widok", "ToggleStagingViewTooltip": "Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany).", "ReturnToFilesPanel": "Wróć do panelu plików", "FastForward": "Szybkie przewijanie", "FastForwardTooltip": "Szybkie przewijanie wybranej gałęzi z jej źródła.", "FastForwarding": "Szybkie przewijanie", "FoundConflictsTitle": "Konflikty!", "ViewConflictsMenuItem": "Pokaż konflikty", "AbortMenuItem": "Przerwij %s", "PickHunk": "Wybierz fragment", "PickAllHunks": "Wybierz wszystkie fragmenty", "ViewMergeRebaseOptions": "Pokaż opcje scalania/rebase", "ViewMergeRebaseOptionsTooltip": "Pokaż opcje do przerwania/kontynuowania/pominięcia bieżącego scalania/rebase.", "ViewMergeOptions": "Pokaż opcje scalania", "ViewRebaseOptions": "Pokaż opcje rebase", "NotMergingOrRebasing": "Aktualnie nie wykonujesz ani scalania, ani rebase", "AlreadyRebasing": "Nie można wykonać tej akcji podczas rebase", "RecentRepos": "Ostatnie repozytoria", "MergeOptionsTitle": "Opcje scalania", "RebaseOptionsTitle": "Opcje rebase", "CommitSummaryTitle": "Podsumowanie commita", "CommitDescriptionTitle": "Opis commita", "CommitDescriptionSubTitle": "Naciśnij {{.togglePanelKeyBinding}}, aby przełączyć fokus, {{.commitMenuKeybinding}}, aby otworzyć menu", "LocalBranchesTitle": "Lokalne gałęzie", "SearchTitle": "Szukaj", "TagsTitle": "Tagi", "CommitMenuTitle": "Menu commita", "RemotesTitle": "Zdalne", "RemoteBranchesTitle": "Zdalne gałęzie", "PatchBuildingTitle": "Główny panel (budowanie łatki)", "InformationTitle": "Informacje", "SecondaryTitle": "Dodatkowy", "ConflictsResolved": "Wszystkie konflikty scalania rozwiązane. Kontynuować?", "Continue": "Kontynuuj", "RebasingFromBaseCommitTitle": "Rebase '{{.checkedOutBranch}}' od oznaczonego commita bazowego", "SimpleRebase": "Prosty rebase na '{{.ref}}'", "InteractiveRebase": "Interaktywny rebase na '{{.ref}}'", "InteractiveRebaseTooltip": "Rozpocznij interaktywny rebase z przerwaniem na początku, abyś mógł zaktualizować commity TODO przed kontynuacją.", "MustSelectTodoCommits": "Podczas rebase ta akcja działa tylko na zaznaczonych commitach TODO.", "FwdNoUpstream": "Nie można szybko przewinąć gałęzi bez źródła", "FwdNoLocalUpstream": "Nie można szybko przewinąć gałęzi, której zdalne źródło nie jest zarejestrowane lokalnie", "FwdCommitsToPush": "Nie można szybko przewinąć gałęzi z commitami do wysłania", "PullRequestNoUpstream": "Nie można otworzyć żądania ściągnięcia dla gałęzi bez źródła", "ErrorOccurred": "Wystąpił błąd! Proszę utworzyć zgłoszenie na", "NoRoom": "Za mało miejsca", "YouAreHere": "JESTEŚ TUTAJ", "YouDied": "ZGINĄŁEŚ!", "RewordNotSupported": "Zmiana słów commitów podczas interaktywnego rebase nie jest obecnie obsługiwana", "ChangingThisActionIsNotAllowed": "Zmiana tego rodzaju wpisu rebase TODO nie jest dozwolona", "CherryPickCopy": "Kopiuj (cherry-pick)", "CherryPickCopyTooltip": "Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `{{.paste}}`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć `{{.escape}}`, aby anulować zaznaczenie.", "CherryPickCopyRangeTooltip": "Oznacz commity jako skopiowane od ostatniego skopiowanego commita do wybranego commita.", "PasteCommits": "Wklej (cherry-pick)", "CannotCherryPickNonCommit": "Nie można cherry-pick tego rodzaju wpisu TODO", "CannotCherryPickMergeCommit": "Cherry-pick commitów scalających nie jest obsługiwane", "Donate": "Wesprzyj", "AskQuestion": "Zadaj pytanie", "PrevLine": "Wybierz poprzednią linię", "NextLine": "Wybierz następną linię", "PrevHunk": "Idź do poprzedniego fragmentu", "NextHunk": "Idź do następnego fragmentu", "PrevConflict": "Poprzedni konflikt", "NextConflict": "Następny konflikt", "SelectPrevHunk": "Poprzedni fragment", "SelectNextHunk": "Następny fragment", "ScrollDown": "Przewiń w dół", "ScrollUp": "Przewiń w górę", "ScrollUpMainWindow": "Przewiń główne okno w górę", "ScrollDownMainWindow": "Przewiń główne okno w dół", "AmendCommitTitle": "Popraw commit", "AmendCommitPrompt": "Czy na pewno chcesz poprawić ten commit swoimi zatwierdzonymi plikami?", "DropCommitTitle": "Usuń commit", "DropCommitPrompt": "Czy na pewno chcesz usunąć wybrane commity?", "PullingStatus": "Ściąganie", "PushingStatus": "Wysyłanie", "FetchingStatus": "Pobieranie", "SquashingStatus": "Sciskanie", "FixingStatus": "Naprawianie", "DeletingStatus": "Usuwanie", "DroppingStatus": "Upuszczanie", "MovingStatus": "Przesuwanie", "RebasingStatus": "Rebase", "MergingStatus": "Scalanie", "LowercaseRebasingStatus": "rebase", "LowercaseMergingStatus": "scalanie", "AmendingStatus": "Poprawianie", "UndoingStatus": "Cofanie", "RedoingStatus": "Ponawianie", "CheckingOutStatus": "Sprawdzanie", "CommittingStatus": "Commitowanie", "RevertingStatus": "Przywracanie", "CreatingFixupCommitStatus": "Tworzenie commita poprawiającego", "CommitFiles": "Zatwierdź pliki", "SubCommitsDynamicTitle": "Commity (%s)", "CommitFilesDynamicTitle": "Pliki różnic (%s)", "RemoteBranchesDynamicTitle": "Zdalne gałęzie (%s)", "ViewItemFiles": "Wyświetl pliki", "ViewItemFilesTooltip": "Wyświetl pliki zmodyfikowane przez wybrany element.", "CommitFilesTitle": "Pliki commita", "CheckoutCommitFileTooltip": "Przełącz plik. Zastępuje plik w twoim drzewie roboczym wersją z wybranego commita.", "CanOnlyDiscardFromLocalCommits": "Można odrzucić tylko zmiany z lokalnych commitów", "Remove": "Usuń", "DiscardOldFileChangeTooltip": "Odrzuć zmiany w tym pliku z tego commita. Uruchamia interaktywny rebase w tle, więc możesz otrzymać konflikt scalania, jeśli późniejszy commit również zmienia ten plik.", "DiscardFileChangesTitle": "Odrzuć zmiany w pliku", "DiscardFileChangesPrompt": "Czy na pewno chcesz usunąć zmiany w wybranym pliku/ach z tego commita?\n\nTa akcja uruchomi rebase, cofając te zmiany w pliku. Pamiętaj, że jeśli późniejsze commity zależą od tych zmian, możesz potrzebować rozwiązać konflikty.\nUwaga: Spowoduje to również zresetowanie wszelkich aktywnych niestandardowych łatek.", "CreateRepo": "Nie w repozytorium git. Utworzyć nowe repozytorium git? (t/n): ", "BareRepo": "Próbujesz otworzyć Lazygit w gołym repozytorium, ale Lazygit jeszcze nie obsługuje gołych repozytoriów. Otworzyć najnowsze repozytorium? (t/n) ", "InitialBranch": "Nazwa gałęzi? (pozostaw puste dla domyślnej gita): ", "NoRecentRepositories": "Musisz otworzyć lazygit w repozytorium git. Brak ważnych ostatnich repozytoriów. Wyjście.", "IncorrectNotARepository": "Wartość 'notARepository' jest nieprawidłowa. Powinna być jedną z 'prompt', 'create', 'skip', lub 'quit'.", "AutoStashPrompt": "Musisz schować i wyciągnąć swoje zmiany, aby je przenieść. Zrobić to automatycznie? (enter/esc)", "StashPrefix": "Automatyczne chowanie zmian dla ", "Discard": "Odrzuć", "DiscardChangesTitle": "Odrzuć zmiany", "DiscardFileChangesTooltip": "Wyświetl opcje odrzucania zmian w wybranym pliku.", "Cancel": "Anuluj", "DiscardAllChanges": "Odrzuć wszystkie zmiany", "DiscardUnstagedChanges": "Odrzuć niezatwierdzone zmiany", "DiscardAllChangesToAllFiles": "Zniszcz drzewo robocze", "DiscardAnyUnstagedChanges": "Odrzuć niezatwierdzone zmiany", "DiscardUntrackedFiles": "Odrzuć nieśledzone pliki", "DiscardStagedChanges": "Odrzuć zatwierdzone zmiany", "HardReset": "Twardy reset", "BranchDeleteTooltip": "Wyświetl opcje usuwania lokalnej/odległej gałęzi.", "TagDeleteTooltip": "Wyświetl opcje usuwania lokalnego/odległego tagu.", "Delete": "Usuń", "ResetTooltip": "Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu.", "FileResetOptionsTooltip": "Wyświetl opcje resetu dla drzewa roboczego (np. zniszczenie drzewa roboczego).", "CreateFixupCommit": "Utwórz commit fixup", "CreateFixupCommitTooltip": "Utwórz commit 'fixup!' dla wybranego commita. Później możesz nacisnąć `{{.squashAbove}}` na tym samym commicie, aby zastosować wszystkie powyższe commity fixup.", "SquashAboveCommitsTooltip": "Scal wszystkie commity 'fixup!', albo powyżej wybranego commita, albo wszystkie w bieżącej gałęzi (autosquash).", "SquashCommitsAboveSelectedTooltip": "Scal wszystkie commity 'fixup!' powyżej wybranego commita (autosquash).", "SquashCommitsInCurrentBranchTooltip": "Scal wszystkie commity 'fixup!' w bieżącej gałęzi (autosquash).", "SquashAboveCommits": "Zastosuj commity fixup", "SquashCommitsInCurrentBranch": "W bieżącej gałęzi", "SquashCommitsAboveSelectedCommit": "Powyżej wybranego commita", "CannotSquashCommitsInCurrentBranch": "Nie można scalić commitów w bieżącej gałęzi: commit HEAD jest commit merge lub jest obecny na głównej gałęzi.", "CommitChangesWithoutHook": "Zatwierdź zmiany bez hooka pre-commit", "ResetTo": "Resetuj do", "ResetSoftTooltip": "Resetuj HEAD do wybranego commita, zachowując zmiany między bieżącym a wybranym commit jako zmiany zatwierdzone.", "ResetMixedTooltip": "Resetuj HEAD do wybranego commita, zachowując zmiany między bieżącym a wybranym commit jako zmiany niezatwierdzone.", "ResetHardTooltip": "Resetuj HEAD do wybranego commita, odrzucając wszystkie zmiany między bieżącym a wybranym commit, jak również wszystkie bieżące modyfikacje w drzewie roboczym.", "PressEnterToReturn": "Naciśnij enter, aby wrócić do lazygit", "ViewStashOptions": "Wyświetl opcje schowka", "ViewStashOptionsTooltip": "Wyświetl opcje schowka (np. schowaj wszystko, schowaj zatwierdzone, schowaj niezatwierdzone).", "Stash": "Schowaj", "StashTooltip": "Schowaj wszystkie zmiany. Dla innych wariantów schowania, użyj klawisza wyświetlania opcji schowka.", "StashAllChanges": "Schowaj wszystkie zmiany", "StashStagedChanges": "Schowaj zatwierdzone zmiany", "StashAllChangesKeepIndex": "Schowaj wszystkie zmiany i zachowaj indeks", "StashUnstagedChanges": "Schowaj niezatwierdzone zmiany", "StashIncludeUntrackedChanges": "Schowaj wszystkie zmiany włącznie z nieśledzonymi plikami", "StashOptions": "Opcje schowka", "NotARepository": "Błąd: musi być uruchomione wewnątrz repozytorium git", "WorkingDirectoryDoesNotExist": "Błąd: bieżący katalog roboczy nie istnieje", "Jump": "Skocz do panelu", "ScrollLeftRight": "Przewiń w lewo/prawo", "ScrollLeft": "Przewiń w lewo", "ScrollRight": "Przewiń w prawo", "DiscardPatch": "Odrzuć łatkę", "DiscardPatchConfirm": "Możesz zbudować łatkę tylko z jednego commita/stanu schowka na raz. Odrzucić bieżącą łatkę?", "CantPatchWhileRebasingError": "Nie można budować łatki ani uruchamiać poleceń łatki podczas scalania lub rebasowania", "ToggleAddToPatch": "Przełącz plik włączony w łatkę", "ToggleAddToPatchTooltip": "Przełącz, czy plik jest włączony w niestandardową łatkę. Zobacz {{.doc}}.", "ToggleAllInPatch": "Przełącz wszystkie pliki", "ToggleAllInPatchTooltip": "Dodaj/usuń wszystkie pliki commita do niestandardowej łatki. Zobacz {{.doc}}.", "UpdatingPatch": "Aktualizowanie łatki", "ViewPatchOptions": "Wyświetl opcje niestandardowej łatki", "PatchOptionsTitle": "Opcje łatki", "NoPatchError": "Brak utworzonej łatki. Aby zacząć budować łatkę, użyj 'spacji' na pliku commita lub enter, aby dodać określone linie", "EmptyPatchError": "Łatka jest nadal pusta. Najpierw dodaj kilka plików lub linii do łatki.", "EnterCommitFile": "Wejdź do pliku / Przełącz zwiń katalog", "EnterCommitFileTooltip": "Jeśli plik jest wybrany, wejdź do pliku, aby móc dodawać/usuwać poszczególne linie do niestandardowej łatki. Jeśli wybrany jest katalog, przełącz katalog.", "ExitCustomPatchBuilder": "Wyjdź z budowniczego niestandardowej łatki", "EnterUpstream": "Wprowadź upstream jako ' '", "InvalidUpstream": "Nieprawidłowy upstream. Musi być w formacie ' '", "ReturnToRemotesList": "Wróć do listy zdalnych", "NewRemote": "Nowy zdalny", "NewRemoteName": "Nowa nazwa zdalnego:", "NewRemoteUrl": "Nowy URL zdalnego:", "ViewBranches": "Wyświetl gałęzie", "EditRemoteName": "Wprowadź zaktualizowaną nazwę zdalnego dla {{.remoteName}}:", "EditRemoteUrl": "Wprowadź zaktualizowany URL zdalnego dla {{.remoteName}}:", "RemoveRemote": "Usuń zdalny", "RemoveRemoteTooltip": "Usuń wybrany zdalny. Wszelkie lokalne gałęzie śledzące gałąź zdalną z tego zdalnego nie zostaną dotknięte.", "DeleteRemoteBranch": "Usuń gałąź zdalną", "DeleteRemoteBranchTooltip": "Usuń gałąź zdalną ze zdalnego.", "SetAsUpstream": "Ustaw jako upstream", "SetAsUpstreamTooltip": "Ustaw wybraną gałąź zdalną jako upstream sprawdzonej gałęzi.", "SetUpstream": "Ustaw upstream wybranej gałęzi", "UnsetUpstream": "Usuń upstream wybranej gałęzi", "ViewDivergenceFromUpstream": "Wyświetl rozbieżność od upstream", "DivergenceSectionHeaderLocal": "Lokalne", "DivergenceSectionHeaderRemote": "Zdalne", "ViewUpstreamResetOptions": "Resetuj sprawdzoną gałąź na {{.upstream}}", "ViewUpstreamResetOptionsTooltip": "Wyświetl opcje resetowania sprawdzonej gałęzi na {{upstream}}. Uwaga: to nie zresetuje wybranej gałęzi na upstream, zresetuje sprawdzoną gałąź na upstream.", "ViewUpstreamRebaseOptions": "Rebase sprawdzonej gałęzi na {{.upstream}}", "ViewUpstreamRebaseOptionsTooltip": "Wyświetl opcje rebasowania sprawdzonej gałęzi na {{upstream}}. Uwaga: to nie zrebase'uje wybranej gałęzi na upstream, zrebase'uje sprawdzoną gałąź na upstream.", "UpstreamGenericName": "upstream wybranej gałęzi", "SetUpstreamTitle": "Ustaw gałąź upstream", "EditRemoteTooltip": "Edytuj nazwę lub URL wybranego zdalnego.", "TagCommit": "Otaguj commit", "TagCommitTooltip": "Utwórz nowy tag wskazujący na wybrany commit. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu.", "TagMenuTitle": "Utwórz tag", "TagNameTitle": "Nazwa tagu", "TagMessageTitle": "Opis tagu", "LightweightTag": "Lekki tag", "AnnotatedTag": "Tag z adnotacją", "DeleteTagTitle": "Usuń tag '{{.tagName}}'?", "DeleteLocalTag": "Usuń lokalny tag", "DeleteRemoteTag": "Usuń zdalny tag", "SelectRemoteTagUpstream": "Zdalny, z którego usunąć tag '{{.tagName}}':", "DeleteRemoteTagPrompt": "Czy na pewno chcesz usunąć zdalny tag '{{.tagName}}' z '{{.upstream}}'?", "RemoteTagDeletedMessage": "Zdalny tag usunięty", "PushTagTitle": "Zdalny, do którego wysłać tag '{{.tagName}}':", "PushTag": "Wyślij tag", "PushTagTooltip": "Wyślij wybrany tag do zdalnego. Zostaniesz poproszony o wybranie zdalnego.", "NewTag": "Nowy tag", "NewTagTooltip": "Utwórz nowy tag z bieżącego commita. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu.", "CreatingTag": "Tworzenie tagu", "ForceTag": "Wymuś Tag", "ForceTagPrompt": "Tag '{{.tagName}}' już istnieje. Naciśnij {{.cancelKey}}, aby anulować, lub {{.confirmKey}}, aby nadpisać.", "FetchRemoteTooltip": "Pobierz aktualizacje z zdalnego repozytorium. Pobiera nowe commity i gałęzie bez scalania ich z lokalnymi gałęziami.", "FetchingRemoteStatus": "Pobieranie zdalnego", "CheckoutCommit": "Przełącz commit", "CheckoutCommitTooltip": "Przełącz wybrany commit jako odłączoną HEAD.", "SureCheckoutThisCommit": "Czy na pewno chcesz przełączyć ten commit?", "GitFlowOptions": "Pokaż opcje git-flow", "NotAGitFlowBranch": "To nie wygląda na gałąź git flow", "NewBranchNamePrompt": "Wprowadź nową nazwę gałęzi dla gałęzi", "IgnoreTracked": "Ignoruj śledzony plik", "ExcludeTracked": "Wyklucz śledzony plik", "IgnoreTrackedPrompt": "Czy na pewno chcesz zignorować śledzony plik?", "ExcludeTrackedPrompt": "Czy na pewno chcesz wykluczyć śledzony plik?", "ViewResetToUpstreamOptions": "Pokaż opcje resetowania do upstream", "NextScreenMode": "Następny tryb ekranu (normalny/półpełny/pełnoekranowy)", "PrevScreenMode": "Poprzedni tryb ekranu", "StartSearch": "Szukaj w bieżącym widoku po tekście", "StartFilter": "Filtruj bieżący widok po tekście", "Keybindings": "Skróty klawiszowe", "KeybindingsLegend": "Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b", "KeybindingsMenuSectionLocal": "Lokalne", "KeybindingsMenuSectionGlobal": "Globalne", "KeybindingsMenuSectionNavigation": "Nawigacja", "RenameBranch": "Zmień nazwę gałęzi", "UpstreamTooltip": "Pokaż opcje upstream dla wybranej gałęzi, np. ustawianie/usuwanie upstream i resetowanie do upstream.", "BranchUpstreamOptionsTitle": "Opcje upstream", "ViewBranchUpstreamOptions": "Pokaż opcje upstream", "ViewBranchUpstreamOptionsTooltip": "Pokaż opcje dotyczące upstream gałęzi, np. ustawianie/usuwanie upstream i resetowanie do upstream.", "UpstreamNotSetError": "Wybrana gałąź nie ma upstream (lub upstream nie jest przechowywany lokalnie)", "NewGitFlowBranchPrompt": "Nowa nazwa {{.branchType}}:", "RenameBranchWarning": "Ta gałąź śledzi zdalną. Ta akcja zmieni tylko lokalną nazwę gałęzi, nie nazwę zdalnej gałęzi. Kontynuować?", "OpenKeybindingsMenu": "Otwórz menu przypisań klawiszy", "ResetCherryPick": "Resetuj wybrane (cherry-picked) commity", "NextTab": "Następna zakładka", "PrevTab": "Poprzednia zakładka", "CantUndoWhileRebasing": "Nie można cofnąć podczas rebasingu", "CantRedoWhileRebasing": "Nie można ponowić podczas rebasingu", "MustStashWarning": "Wyjęcie łatki do indeksu wymaga schowania i odschowania zmian. Jeśli coś pójdzie nie tak, będziesz mógł uzyskać dostęp do plików ze schowka. Kontynuować?", "MustStashTitle": "Musisz schować", "ConfirmationTitle": "Panel potwierdzenia", "PrevPage": "Poprzednia strona", "NextPage": "Następna strona", "GotoTop": "Przewiń do góry", "GotoBottom": "Przewiń do dołu", "FilteringBy": "Filtrowanie przez", "ResetInParentheses": "(Resetuj)", "OpenFilteringMenu": "Pokaż opcje filtrowania", "OpenFilteringMenuTooltip": "Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra.", "FilterBy": "Filtruj przez", "ExitFilterMode": "Zatrzymaj filtrowanie", "FilterPathOption": "Wprowadź ścieżkę do filtrowania", "FilterAuthorOption": "Wprowadź autora do filtrowania", "EnterFileName": "Wprowadź ścieżkę:", "EnterAuthor": "Wprowadź autora:", "FilteringMenuTitle": "Filtrowanie", "WillCancelExistingFilterTooltip": "Uwaga: to anuluje istniejący filtr", "MustExitFilterModeTitle": "Polecenie niedostępne", "MustExitFilterModePrompt": "Polecenie niedostępne w trybie filtrowania po ścieżce. Wyjść z trybu filtrowania po ścieżce?", "Diff": "Różnice", "EnterRefToDiff": "Wprowadź ref do różnic", "EnterRefName": "Wprowadź ref:", "ExitDiffMode": "Wyjdź z trybu różnic", "DiffingMenuTitle": "Różnicowanie", "SwapDiff": "Odwróć kierunek różnic", "ViewDiffingOptions": "Pokaż opcje różnicowania", "ViewDiffingOptionsTooltip": "Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic.", "OpenCommandLogMenu": "Pokaż opcje dziennika poleceń", "OpenCommandLogMenuTooltip": "Pokaż opcje dla dziennika poleceń, np. pokazywanie/ukrywanie dziennika poleceń i skupienie na dzienniku poleceń.", "ShowingGitDiff": "Pokazuje wynik dla:", "CommitDiff": "Różnice commita", "CopyCommitHashToClipboard": "Kopiuj hash commita do schowka", "CommitHash": "hash commita", "CommitURL": "URL commita", "CopyCommitMessageToClipboard": "Kopiuj wiadomość commita do schowka", "CommitMessage": "Wiadomość commita", "CommitSubject": "Temat commita", "CommitAuthor": "Autor commita", "CopyCommitAttributeToClipboard": "Kopiuj atrybut commita do schowka", "CopyCommitAttributeToClipboardTooltip": "Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor).", "CopyBranchNameToClipboard": "Kopiuj nazwę gałęzi do schowka", "CopyPathToClipboard": "Kopiuj ścieżkę do schowka", "CommitPrefixPatternError": "Błąd w wzorcu commitPrefix", "CopySelectedTextToClipboard": "Kopiuj zaznaczony tekst do schowka", "NoFilesStagedTitle": "Brak plików przygotowanych", "NoFilesStagedPrompt": "Nie przygotowałeś żadnych plików. Zatwierdzić wszystkie pliki?", "BranchNotFoundTitle": "Gałąź nie znaleziona", "BranchNotFoundPrompt": "Gałąź nie znaleziona. Utwórz nową gałąź o nazwie", "BranchUnknown": "Gałąź nieznana", "DiscardChangeTitle": "Odrzuć zmianę", "DiscardChangePrompt": "Czy na pewno chcesz odrzucić tę zmianę (git reset)? Jest to nieodwracalne.\nAby wyłączyć to okno dialogowe, ustaw klucz konfiguracyjny 'gui.skipDiscardChangeWarning' na true", "CreateNewBranchFromCommit": "Utwórz nową gałąź z commita", "BuildingPatch": "Tworzenie łatki", "ViewCommits": "Pokaż commity", "RunningCustomCommandStatus": "Uruchamianie niestandardowego polecenia", "SubmoduleStashAndReset": "Schowaj niezatwierdzone zmiany submodułu i zaktualizuj", "AndResetSubmodules": "I zresetuj submoduły", "EnterSubmoduleTooltip": "Wejdź do submodułu. Po wejściu do submodułu możesz nacisnąć `{{.escape}}`, aby wrócić do repozytorium nadrzędnego.", "Enter": "Wejdź", "CopySubmoduleNameToClipboard": "Kopiuj nazwę submodułu do schowka", "RemoveSubmodule": "Usuń submoduł", "RemoveSubmoduleTooltip": "Usuń wybrany submoduł i odpowiadający mu katalog.", "RemoveSubmodulePrompt": "Czy na pewno chcesz usunąć submoduł '%s' i odpowiadający mu katalog? Jest to nieodwracalne.", "ResettingSubmoduleStatus": "Resetowanie submodułu", "NewSubmoduleName": "Nowa nazwa submodułu:", "NewSubmoduleUrl": "Nowy URL submodułu:", "NewSubmodulePath": "Nowa ścieżka submodułu:", "NewSubmodule": "Nowy submoduł", "AddingSubmoduleStatus": "Dodawanie submodułu", "UpdateSubmoduleUrl": "Zaktualizuj URL dla submodułu '%s'", "UpdatingSubmoduleUrlStatus": "Aktualizowanie URL", "EditSubmoduleUrl": "Zaktualizuj URL submodułu", "InitializingSubmoduleStatus": "Inicjalizowanie submodułu", "InitSubmoduleTooltip": "Zainicjuj wybrany submoduł, aby przygotować do pobrania. Prawdopodobnie chcesz to kontynuować, wywołując akcję 'update', aby pobrać submoduł.", "Update": "Aktualizuj", "Initialize": "Zainicjuj", "SubmoduleUpdateTooltip": "Aktualizuj wybrany submoduł.", "UpdatingSubmoduleStatus": "Aktualizowanie submodułu", "BulkInitSubmodules": "Masowe inicjowanie submodułów", "BulkUpdateSubmodules": "Masowa aktualizacja submodułów", "BulkDeinitSubmodules": "Masowe wyłączanie submodułów", "ViewBulkSubmoduleOptions": "Pokaż opcje masowych operacji na submodułach", "BulkSubmoduleOptions": "Opcje masowych operacji na submodułach", "RunningCommand": "Uruchamianie polecenia", "SubCommitsTitle": "Sub-commity", "SubmodulesTitle": "Submoduły", "NavigationTitle": "Nawigacja panelu listy", "SuggestionsCheatsheetTitle": "Podpowiedzi", "SuggestionsTitle": "Podpowiedzi (naciśnij %s, aby skupić)", "ExtrasTitle": "Dziennik poleceń", "PushingTagStatus": "Wysyłanie tagu", "PullRequestURLCopiedToClipboard": "URL żądania ściągnięcia skopiowany do schowka", "CommitDiffCopiedToClipboard": "Różnice commita skopiowane do schowka", "CommitURLCopiedToClipboard": "URL commita skopiowany do schowka", "CommitMessageCopiedToClipboard": "Wiadomość commita skopiowana do schowka", "CommitSubjectCopiedToClipboard": "Temat commita skopiowany do schowka", "CommitAuthorCopiedToClipboard": "Autor commita skopiowany do schowka", "PatchCopiedToClipboard": "Łatka skopiowana do schowka", "CopiedToClipboard": "skopiowane do schowka", "ErrCannotEditDirectory": "Nie można edytować katalogu: można edytować tylko pojedyncze pliki", "ErrStageDirWithInlineMergeConflicts": "Nie można przygotować/odprzygotować katalogu zawierającego pliki z konfliktami scalania w linii. Proszę najpierw rozwiązać konflikty scalania", "ErrRepositoryMovedOrDeleted": "Nie można znaleźć repozytorium. Mogło zostać przeniesione lub usunięte ¯\\_(ツ)_/¯", "ErrWorktreeMovedOrRemoved": "Nie można znaleźć drzewa roboczego. Mogło zostać przeniesione lub usunięte ¯\\_(ツ)_/¯", "CommandLog": "Dziennik poleceń", "ToggleShowCommandLog": "Przełącz pokazywanie/ukrywanie dziennika poleceń", "FocusCommandLog": "Skup na dzienniku poleceń", "CommandLogHeader": "Możesz ukryć/skupić się na tym panelu naciskając '%s'\n", "RandomTip": "Losowa porada", "SelectParentCommitForMerge": "Wybierz nadrzędny commit do scalenia", "ToggleWhitespaceInDiffView": "Przełącz białe znaki", "ToggleWhitespaceInDiffViewTooltip": "Przełącz czy zmiany białych znaków są pokazywane w widoku różnic.", "IgnoreWhitespaceDiffViewSubTitle": "(ignorując białe znaki)", "IgnoreWhitespaceNotSupportedHere": "Ignorowanie białych znaków nie jest wspierane w tym widoku", "IncreaseContextInDiffView": "Zwiększ rozmiar kontekstu w widoku różnic", "IncreaseContextInDiffViewTooltip": "Zwiększ ilość kontekstu pokazywanego wokół zmian w widoku różnic.", "DecreaseContextInDiffView": "Zmniejsz rozmiar kontekstu w widoku różnic", "DecreaseContextInDiffViewTooltip": "Zmniejsz ilość kontekstu pokazywanego wokół zmian w widoku różnic.", "DiffContextSizeChanged": "Zmieniono rozmiar kontekstu różnic na %d", "CreatePullRequestOptions": "Zobacz opcje tworzenia pull requesta", "DefaultBranch": "Domyślny branch", "SelectBranch": "Wybierz branch", "CreatePullRequest": "Utwórz żądanie ściągnięcia", "SelectConfigFile": "Wybierz plik konfiguracyjny", "NoConfigFileFoundErr": "Nie znaleziono pliku konfiguracyjnego", "LoadingFileSuggestions": "Ładowanie sugestii plików", "LoadingCommits": "Ładowanie commitów", "MustSpecifyOriginError": "Musisz określić zdalne repozytorium jeśli określasz branch", "GitOutput": "Wyjście Gita:", "GitCommandFailed": "Polecenie Gita nie powiodło się. Sprawdź logi poleceń po szczegóły (otwórz za pomocą %s)", "AbortTitle": "Przerwij %s", "AbortPrompt": "Czy na pewno chcesz przerwać bieżące %s?", "OpenLogMenu": "Zobacz opcje logów", "OpenLogMenuTooltip": "Zobacz opcje dla logów commitów, np. zmiana kolejności sortowania, ukrywanie grafu gita, pokazywanie całego grafu gita.", "LogMenuTitle": "Opcje logów commitów", "ToggleShowGitGraphAll": "Przełącz pokazanie całego grafu gita (dodaj flagę `--all` do `git log`)", "ShowGitGraph": "Pokaż graf gita", "SortOrder": "Kolejność sortowania", "SortAlphabetical": "Alfabetycznie", "SortByDate": "Data", "SortByRecency": "Najnowsze", "SortBasedOnReflog": "(na podstawie reflog)", "SortCommits": "Kolejność sortowania commitów", "CantChangeContextSizeError": "Nie można zmienić rozmiaru kontekstu będąc w trybie budowania patcha, ponieważ byliśmy zbyt leniwi, aby to wspierać przy wydaniu funkcji. Jeśli naprawdę tego chcesz, daj nam znać!", "OpenCommitInBrowser": "Otwórz commit w przeglądarce", "ViewBisectOptions": "Zobacz opcje bisect", "ConfirmRevertCommit": "Czy na pewno chcesz cofnąć {{.selectedCommit}}?", "RewordInEditorTitle": "Przeformułuj w edytorze", "RewordInEditorPrompt": "Czy na pewno chcesz przeformułować ten commit w swoim edytorze?", "HardResetAutostashPrompt": "Czy na pewno chcesz zrobić twardy reset do '%s'? Auto-stash zostanie wykonany jeśli będzie potrzebny.", "UpstreamGone": "(upstream zniknął)", "NukeDescription": "Jeśli chcesz, aby wszystkie zmiany w drzewie pracy zniknęły, to jest sposób na to. Jeśli są brudne zmiany w submodule, to zostaną one zapisane w submodule(s).", "DiscardStagedChangesDescription": "To stworzy nowy wpis stash zawierający tylko pliki w stanie staged, a następnie go usunie, tak że drzewo pracy zostanie tylko ze zmianami niezatwierdzonymi", "EmptyOutput": "", "CustomPatch": "Niestandardowy patch", "CommitsCopied": "commitów skopiowanych", "CommitCopied": "commit skopiowany", "ResetPatch": "Resetuj patch", "ResetPatchTooltip": "Wyczyść bieżący patch.", "ApplyPatch": "Zastosuj patch", "ApplyPatchTooltip": "Zastosuj bieżący patch do drzewa pracy.", "ApplyPatchInReverse": "Zastosuj patch w odwrotności", "ApplyPatchInReverseTooltip": "Zastosuj bieżący patch w odwrotności do drzewa pracy.", "RemovePatchFromOriginalCommit": "Usuń patch z oryginalnego commita (%s)", "RemovePatchFromOriginalCommitTooltip": "Usuń bieżący patch z jego commita. Jest to osiągane przez rozpoczęcie interaktywnego rebase na commicie, zastosowanie patcha w odwrotności, a następnie kontynuowanie rebase. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", "MovePatchOutIntoIndex": "Przenieś patch do indeksu", "MovePatchOutIntoIndexTooltip": "Przenieś patch z jego commita do indeksu. Jest to osiągane przez rozpoczęcie interaktywnego rebase na commicie, zastosowanie patcha w odwrotności, kontynuowanie rebase do zakończenia, a następnie zastosowanie patcha do indeksu. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", "MovePatchIntoNewCommit": "Przenieś patch do nowego commita", "MovePatchIntoNewCommitTooltip": "Przenieś patch z jego commita do nowego commita na górze oryginalnego commita. Jest to osiągane przez rozpoczęcie interaktywnego rebase na oryginalnym commicie, zastosowanie patcha w odwrotności, następnie zastosowanie patcha do indeksu i zatwierdzenie go jako nowy commit, przed kontynuowaniem rebase do zakończenia. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", "MovePatchToSelectedCommit": "Przenieś patch do wybranego commita (%s)", "MovePatchToSelectedCommitTooltip": "Przenieś patch z jego oryginalnego commita do wybranego commita. Jest to osiągane przez rozpoczęcie interaktywnego rebase na oryginalnym commicie, zastosowanie patcha w odwrotności, następnie kontynuowanie rebase do wybranego commita, przed zastosowaniem patcha do przodu i zmodyfikowaniem wybranego commita. Rebase jest następnie kontynuowany do zakończenia. Jeśli commity między źródłem a miejscem docelowym zależą od patcha, możesz musieć rozwiązać konflikty.", "CopyPatchToClipboard": "Kopiuj patch do schowka", "NoMatchesFor": "Brak dopasowań dla '%s' %s", "MatchesFor": "dopasowania dla '%s' (%d z %d) %s", "SearchKeybindings": "%s: Następne dopasowanie, %s: Poprzednie dopasowanie, %s: Wyjdź z trybu wyszukiwania", "SearchPrefix": "Szukaj: ", "FilterPrefix": "Filtruj: ", "ExitSearchMode": "%s: Wyjdź z trybu wyszukiwania", "ExitTextFilterMode": "%s: Wyjdź z trybu filtrowania", "Switch": "Przełącz", "SwitchToWorktree": "Przełącz do drzewa pracy", "SwitchToWorktreeTooltip": "Przełącz do wybranego drzewa pracy.", "AlreadyCheckedOutByWorktree": "Ten branch jest już używany przez drzewo pracy {{.worktreeName}}. Czy chcesz przełączyć się do tego drzewa pracy?", "BranchCheckedOutByWorktree": "Branch {{.branchName}} jest używany przez drzewo pracy {{.worktreeName}}", "DetachWorktreeTooltip": "To uruchomi `git checkout --detach` na drzewie pracy, tak że przestanie ono używać brancha, ale drzewo pracy drzewa pracy zostanie nietknięte.", "Switching": "Przełączanie", "RemoveWorktree": "Usuń drzewo pracy", "RemoveWorktreeTitle": "Usuń drzewo pracy", "DetachWorktree": "Odłącz drzewo pracy", "DetachingWorktree": "Odłączanie drzewa pracy", "WorktreesTitle": "Drzewa pracy", "WorktreeTitle": "Drzewo pracy", "RemoveWorktreePrompt": "Czy na pewno chcesz usunąć drzewo pracy '{{.worktreeName}}'?", "ForceRemoveWorktreePrompt": "'{{.worktreeName}}' zawiera zmodyfikowane lub nieśledzone pliki (szczerze mówiąc, może zawierać oba). Czy na pewno chcesz to usunąć?", "RemovingWorktree": "Usuwanie drzewa pracy", "AddingWorktree": "Dodawanie drzewa pracy", "CantDeleteCurrentWorktree": "Nie możesz usunąć bieżącego drzewa pracy!", "AlreadyInWorktree": "Jesteś już w wybranym drzewie pracy", "CantDeleteMainWorktree": "Nie możesz usunąć głównego drzewa pracy!", "NoWorktreesThisRepo": "Brak drzew pracy", "MissingWorktree": "(brakujące)", "MainWorktree": "(główne)", "NewWorktree": "Nowe drzewo pracy", "NewWorktreePath": "Nowa ścieżka drzewa pracy", "NewWorktreeBase": "Nowa bazowa ref drzewa pracy", "RemoveWorktreeTooltip": "Usuń wybrane drzewo pracy. To usunie zarówno katalog drzewa pracy, jak i metadane o drzewie pracy w katalogu .git.", "BranchNameCannotBeBlank": "Nazwa brancha nie może być pusta", "NewBranchName": "Nowa nazwa brancha", "NewBranchNameLeaveBlank": "Nowa nazwa brancha (pozostaw puste, aby przełączyć {{.default}})", "ViewWorktreeOptions": "Zobacz opcje drzewa pracy", "CreateWorktreeFrom": "Utwórz drzewo pracy z {{.ref}}", "CreateWorktreeFromDetached": "Utwórz drzewo pracy z {{.ref}} (odłączone)", "LcWorktree": "drzewo pracy", "ChangingDirectoryTo": "Zmiana katalogu na {{.path}}", "Name": "Nazwa", "Path": "Ścieżka", "MarkedBaseCommitStatus": "Oznaczono bazowy commit dla rebase", "MarkAsBaseCommit": "Oznacz jako bazowy commit dla rebase", "MarkAsBaseCommitTooltip": "Wybierz bazowy commit dla następnego rebase. Kiedy robisz rebase na branch, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`.", "MarkedCommitMarker": "↑↑↑ Rebase rozpocznie się stąd ↑↑↑", "NoCopiedCommits": "Brak skopiowanych commitów", "DisabledMenuItemPrefix": "Wyłączone: ", "QuickStartInteractiveRebase": "Rozpocznij interaktywny rebase", "QuickStartInteractiveRebaseTooltip": "Rozpocznij interaktywny rebase dla commitów na twoim branchu. To będzie zawierać wszystkie commity od HEAD do pierwszego commita scalenia lub commita głównego brancha.\nJeśli chcesz zamiast tego rozpocząć interaktywny rebase od wybranego commita, naciśnij `{{.editKey}}`.", "CannotQuickStartInteractiveRebase": "Nie można rozpocząć interaktywnego rebase: commit HEAD jest commit'em scalenia lub jest obecny na głównym branchu, więc nie ma odpowiedniego bazowego commita, od którego można by zacząć rebase. Możesz rozpocząć interaktywny rebase z konkretnego commita, wybierając commit i naciskając `{{.editKey}}`.", "ToggleRangeSelect": "Przełącz zaznaczenie zakresu", "RangeSelectUp": "Zaznacz zakres w górę", "RangeSelectDown": "Zaznacz zakres w dół", "RangeSelectNotSupported": "Akcja nie wspiera zaznaczania zakresu, proszę wybrać pojedynczy element", "NoItemSelected": "Nie wybrano elementu", "SelectedItemIsNotABranch": "Wybrany element nie jest branch'em", "SelectedItemDoesNotHaveFiles": "Wybrany element nie ma plików do wyświetlenia", "OldCherryPickKeyWarning": "Klawisz 'c' nie jest już domyślnym klawiszem do kopiowania commitów do cherry pick. Proszę użyj `{{.copy}}` zamiast tego (i `{{.paste}}` aby wkleić). Powodem tej zmiany jest to, że klawisz 'v' do wybierania zakresu linii podczas stagingu jest teraz używany również do wybierania zakresu linii w każdym widoku listy, co oznacza, że musieliśmy znaleźć nowy klawisz do wklejania commitów, i jeśli zamierzamy teraz używać `{{.paste}}` do wklejania commitów, możemy równie dobrze użyć `{{.copy}}` do ich kopiowania. Jeśli chcesz skonfigurować klawisze, aby uzyskać stare zachowanie, ustaw następujące w swojej konfiguracji:\n\nkeybinding:\n universal:\n toggleRangeSelect: \n commits:\n cherryPickCopy: 'c'\n pasteCommits: 'v'", "Actions": { "CheckoutCommit": "Przełącz commit", "CheckoutTag": "Przełącz tag", "CheckoutBranch": "Przełącz gałąź", "ForceCheckoutBranch": "Wymuś przełączenie gałęzi", "DeleteLocalBranch": "Usuń lokalną gałąź", "Merge": "Scal", "RebaseBranch": "Rebazuj gałąź", "RenameBranch": "Zmień nazwę gałęzi", "CreateBranch": "Utwórz gałąź", "FastForwardBranch": "Szybkie przewijanie gałęzi", "CherryPick": "(Cherry-pick) wklej commity", "CheckoutFile": "Przełącz plik", "DiscardOldFileChange": "Odrzuć starą zmianę w pliku", "SquashCommitDown": "Scal commit w dół", "FixupCommit": "Popraw commit", "RewordCommit": "Zmień treść commita", "DropCommit": "Odrzuć commit", "EditCommit": "Edytuj commit", "AmendCommit": "Popraw commit", "ResetCommitAuthor": "Zresetuj autora commita", "SetCommitAuthor": "Ustaw autora commita", "RevertCommit": "Cofnij commit", "CreateFixupCommit": "Utwórz commit poprawkowy", "SquashAllAboveFixupCommits": "Scal wszystkie powyższe commity poprawkowe", "MoveCommitUp": "Przenieś commit w górę", "MoveCommitDown": "Przenieś commit w dół", "CopyCommitMessageToClipboard": "Kopiuj wiadomość commita do schowka", "CopyCommitSubjectToClipboard": "Kopiuj temat commita do schowka", "CopyCommitDiffToClipboard": "Kopiuj różnice commita do schowka", "CopyCommitHashToClipboard": "Kopiuj hash commita do schowka", "CopyCommitURLToClipboard": "Kopiuj URL commita do schowka", "CopyCommitAuthorToClipboard": "Kopiuj autora commita do schowka", "CopyCommitAttributeToClipboard": "Kopiuj do schowka", "CopyPatchToClipboard": "Kopiuj łatkę do schowka", "CustomCommand": "Polecenie niestandardowe", "DiscardAllChangesInDirectory": "Odrzuć wszystkie zmiany w katalogu", "DiscardUnstagedChangesInDirectory": "Odrzuć niezatwierdzone zmiany w katalogu", "DiscardAllChangesInFile": "Odrzuć wszystkie zmiany w wybranych plikach", "DiscardAllUnstagedChangesInFile": "Odrzuć wszystkie niezatwierdzone zmiany w wybranych plikach", "StageFile": "Dodaj plik do indeksu", "StageResolvedFiles": "Dodaj pliki, których konflikty scalania zostały rozwiązane", "UnstageFile": "Usuń plik z indeksu", "UnstageAllFiles": "Usuń wszystkie pliki z indeksu", "StageAllFiles": "Dodaj wszystkie pliki do indeksu", "IgnoreExcludeFile": "Ignoruj lub wyklucz plik", "IgnoreFileErr": "Nie można zignorować .gitignore", "ExcludeFile": "Wyklucz plik", "ExcludeGitIgnoreErr": "Nie można wykluczyć .gitignore", "Commit": "Commituj", "EditFile": "Edytuj plik", "Push": "Wypchnij", "Pull": "Pociągnij", "OpenFile": "Otwórz plik", "StashAllChanges": "Schowaj wszystkie zmiany", "StashAllChangesKeepIndex": "Schowaj wszystkie zmiany i zachowaj indeks", "StashStagedChanges": "Schowaj zatwierdzone zmiany", "StashUnstagedChanges": "Schowaj niezatwierdzone zmiany", "StashIncludeUntrackedChanges": "Schowaj wszystkie zmiany włącznie z nieśledzonymi plikami", "GitFlowFinish": "git flow zakończ", "GitFlowStart": "git flow rozpocznij", "CopyToClipboard": "Kopiuj do schowka", "CopySelectedTextToClipboard": "Kopiuj zaznaczony tekst do schowka", "RemovePatchFromCommit": "Usuń łatkę z commita", "MovePatchToSelectedCommit": "Przenieś łatkę do wybranego commita", "MovePatchIntoIndex": "Przenieś łatkę do indeksu", "MovePatchIntoNewCommit": "Przenieś łatkę do nowego commita", "DeleteRemoteBranch": "Usuń zdalną gałąź", "SetBranchUpstream": "Ustaw gałąź nadrzędną", "AddRemote": "Dodaj zdalne", "RemoveRemote": "Usuń zdalne", "UpdateRemote": "Aktualizuj zdalne", "ApplyPatch": "Zastosuj łatkę", "Stash": "Schowaj", "RenameStash": "Zmień nazwę schowka", "RemoveSubmodule": "Usuń podmoduł", "ResetSubmodule": "Resetuj podmoduł", "AddSubmodule": "Dodaj podmoduł", "UpdateSubmoduleUrl": "Aktualizuj URL podmodułu", "InitialiseSubmodule": "Zainicjuj podmoduł", "BulkInitialiseSubmodules": "Masowo zainicjuj podmoduły", "BulkUpdateSubmodules": "Masowo aktualizuj podmoduły", "BulkDeinitialiseSubmodules": "Masowo deinicjuj podmoduły", "UpdateSubmodule": "Aktualizuj podmoduł", "CreateLightweightTag": "Utwórz lekki tag", "CreateAnnotatedTag": "Utwórz opisowy tag", "DeleteLocalTag": "Usuń lokalny tag", "DeleteRemoteTag": "Usuń zdalny tag", "PushTag": "Wypchnij tag", "NukeWorkingTree": "Zniszcz drzewo robocze", "DiscardUnstagedFileChanges": "Odrzuć niezatwierdzone zmiany w pliku", "RemoveUntrackedFiles": "Usuń nieśledzone pliki", "RemoveStagedFiles": "Usuń zatwierdzone pliki", "SoftReset": "Miękki reset", "MixedReset": "Mieszany reset", "HardReset": "Twardy reset", "Undo": "Cofnij", "Redo": "Ponów", "CopyPullRequestURL": "Kopiuj URL żądania ściągnięcia", "OpenDiffTool": "Otwórz narzędzie różnic", "OpenMergeTool": "Otwórz narzędzie scalania", "OpenCommitInBrowser": "Otwórz commit w przeglądarce", "OpenPullRequest": "Otwórz żądanie ściągnięcia w przeglądarce", "StartBisect": "Rozpocznij bisect", "ResetBisect": "Resetuj bisect", "BisectSkip": "Pomiń bisect", "BisectMark": "Oznacz bisect", "RemoveWorktree": "Usuń drzewo robocze", "AddWorktree": "Dodaj drzewo robocze" }, "Bisect": { "MarkStart": "Oznacz %s jako %s (rozpocznij bisect)", "ResetTitle": "Resetuj 'git bisect'", "ResetPrompt": "Czy na pewno chcesz zresetować 'git bisect'?", "ResetOption": "Resetuj bisect", "ChooseTerms": "Wybierz terminy bisect", "OldTermPrompt": "Termin dla starego/dobrego commita:", "NewTermPrompt": "Termin dla nowego/złego commita:", "Mark": "Oznacz bieżący commit (%s) jako %s", "SkipCurrent": "Pomiń bieżący commit (%s)", "SkipSelected": "Pomiń wybrany commit (%s)", "CompleteTitle": "Bisect zakończony", "CompletePrompt": "Bisect zakończony! Następujący commit wprowadził zmianę:\n\n%s\n\nCzy chcesz teraz zresetować 'git bisect'?", "CompletePromptIndeterminate": "Bisect zakończony! Niektóre commity zostały pominięte, więc którykolwiek z następujących commitów mógł wprowadzić zmianę:\n\n%s\n\nCzy chcesz teraz zresetować 'git bisect'?", "Bisecting": "Bisectowanie" }, "Log": { "EditRebase": "Rozpoczynanie interaktywnego rebazowania od '{{.ref}}'", "MoveCommitUp": "Przenoszenie TODO w dół: '{{.shortHash}}'", "MoveCommitDown": "Przenoszenie TODO w dół: '{{.shortHash}}'", "CherryPickCommits": "Cherry-picking commitów:\n'{{.commitLines}}'", "HandleUndo": "Cofanie ostatniego rozwiązania konfliktu", "HandleMidRebaseCommand": "Aktualizacja akcji rebazowania commita {{.shortHash}} na '{{.action}}'", "RemoveFile": "Usuwanie ścieżki '{{.path}}'", "CopyToClipboard": "Kopiowanie '{{.str}}' do schowka", "Remove": "Usuwanie '{{.filename}}'", "CreateFileWithContent": "Tworzenie pliku '{{.path}}'", "AppendingLineToFile": "Dodawanie '{{.line}}' do pliku '{{.filename}}'", "EditRebaseFromBaseCommit": "Rozpoczynanie interaktywnego rebazowania od '{{.baseCommit}}' na '{{.targetBranchName}}" }, "BreakingChangesTitle": "Zmiany przełomowe", "BreakingChangesMessage": "Aktualizujesz do nowej wersji lazygit, która zawiera zmiany przełomowe. Proszę przejrzeć poniższe notatki i zaktualizować swoją konfigurację, jeśli jest to konieczne.\nAby uzyskać więcej informacji, zobacz pełne notatki do wydania na .", "BreakingChangesByVersion": { "0.41.0": "- Gdy naciśniesz 'g', aby wywołać menu resetu git, opcja 'mixed' jest teraz pierwsza i domyślna, a nie 'soft'. Jest to dlatego, że 'mixed' jest najczęściej używaną opcją.\n- Panel wiadomości commita teraz domyślnie zawija tekst (tj. dodaje znaki nowej linii, gdy osiągniesz margines). Możesz dostosować konfigurację w następujący sposób:\n\ngit:\n commit:\n autoWrapCommitMessage: true\n autoWrapWidth: 72\n\n- Klawisz 'v' był już używany w widoku staging do rozpoczęcia zaznaczania zakresu, ale teraz możesz go użyć do rozpoczęcia zaznaczania zakresu w dowolnym widoku. Niestety koliduje to z klawiszem 'v' dla wklejania commitów (cherry-pick), więc teraz wklejanie commitów odbywa się za pomocą 'shift+V', a dla spójności kopiowanie commitów odbywa się teraz za pomocą 'shift+C' zamiast 'c'. Zauważ, że klawisz 'v' to tylko jeden ze sposobów na rozpoczęcie zaznaczania zakresu: możesz zamiast tego użyć shift+góra/dół. Więc jeśli chcesz skonfigurować klawisze cherry-pick, aby uzyskać stare zachowanie, ustaw następujące w swojej konfiguracji:\n\nkeybinding:\n universal:\n toggleRangeSelect: \n commits:\n cherryPickCopy: 'c'\n pasteCommits: 'v'\n\n- Sciskanie fixupów za pomocą 'shift-S' teraz wywołuje menu, z domyślną opcją sciskania wszystkich commitów fixup w gałęzi. Oryginalne zachowanie sciskania tylko commitów fixup powyżej wybranego commita jest nadal dostępne jako druga opcja w tym menu.\n- Statusy ładowania push/pull/fetch są teraz wyświetlane przy gałęzi, a nie w popupie. Pozwala to np. na równoczesne fetchowanie wielu gałęzi i widzenie statusu dla każdej gałęzi.\n- Graf logu git w widoku commitów jest teraz zawsze wyświetlany domyślnie (wcześniej był wyświetlany tylko, gdy widok był maksymalizowany). Jeśli uznasz to za zbyt hałaśliwe, możesz to zmienić za pomocą ctrl+L -> 'Pokaż graf git' -> 'gdy maksymalizowany'\n\t " } } lazygit-0.50.0+ds1/pkg/i18n/translations/pt.json000066400000000000000000001125301500612110400213310ustar00rootroot00000000000000{ "NotEnoughSpace": "Espaço insuficiente para renderizar painéis", "DiffTitle": "Diff", "FilesTitle": "Arquivos", "BranchesTitle": "Branches", "CommitsTitle": "Commits", "StashTitle": "Stash", "SnakeTitle": "Snake", "EasterEgg": "Easter Egg", "UnstagedChanges": "Alterações não preparadas", "StagedChanges": "Alterações preparadas", "MainTitle": "Main", "StagingTitle": "Painel Principal (preparação)", "MergingTitle": "Painel principal (mesclagem)", "SquashMergeUncommittedTitle": "Mesclar Squash e sair sem commit", "SquashMergeCommittedTitle": "Mesclar Squash e commit", "SquashMergeUncommitted": "Mesclar Squash '{{.selectedBranch}}' na árvore de trabalho", "SquashMergeCommitted": "Mesclar Squash '{{.selectedBranch}}' em '{{.checkedOutBranch}}' como um único commit.", "RegularMergeTooltip": "Mesclar '{{.selectedBranch}}' em '{{.checkedOutBranch}}'.", "NormalTitle": "Painel Principal (Normal)", "LogTitle": "Log", "CommitSummary": "Sumário do commit", "CredentialsUsername": "Nome de usuário", "CredentialsPassword": "Senha", "CredentialsPassphrase": "Digite a senha para a chave SSH", "CredentialsPIN": "Digite o PIN para a chave SSH", "CredentialsToken": "Digite o Token para chave SSH", "PassUnameWrong": "Senha, palavra-chave e/ou nome de usuário incorreto", "Commit": "Commit", "CommitTooltip": "Submeter mudanças em staging", "AmendLastCommit": "Alterar último commit", "AmendLastCommitTitle": "Alterar último commit", "SureToAmend": "Está certo de querer alterar o último commit? Posteriormente, pode alterar a mensagem do commit do painel de commits", "NoCommitToAmend": "Não há commit para alterar.", "CommitChangesWithEditor": "Enviar alteração usando um editor Git", "FindBaseCommitForFixup": "Encontrar commit da base para consertar", "FindBaseCommitForFixupTooltip": "Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado\nVeja a documentação:\n", "NoBaseCommitsFound": "Nenhum commit base encontrado", "MultipleBaseCommitsFoundStaged": "Múltiplos commits da base encontrados.", "MultipleBaseCommitsFoundUnstaged": "Múltiplos commits da base encontrados. (Tente preparar alguma dessas mudanças)", "BaseCommitIsAlreadyOnMainBranch": "O commit da base para está mudança já está na branch main", "BaseCommitIsNotInCurrentView": "O commit da base não está na visão atual", "HunksWithOnlyAddedLinesWarning": "Existem intervalos apenas de linhas adicionadas no diff; tenha cuidado para verificar se elas pertencem ao commit base encontrado.\n\nProceder?", "StatusTitle": "Status", "GlobalTitle": "Combinações globais de teclas", "Menu": "Menu", "Execute": "Executar", "Stage": "Etapa", "StageTooltip": "Alternar para staging para o arquivo selecionado.", "ToggleStagedAll": "Stage completo", "ToggleStagedAllTooltip": "Alternar para todos os arquivos na árvore de trabalho", "ToggleTreeView": "Alternar exibição de árvore de arquivo", "ToggleTreeViewTooltip": "Alternar a visualização de arquivo entre layout plano e layout de árvore. Layout plano mostra todos os caminhos de arquivo em uma única lista, layout de árvore agrupa arquivos por diretório.", "OpenDiffTool": "Abrir ferramenta de diff externa (git difftool)", "OpenMergeTool": "Abrir ferramenta de merge externa", "OpenMergeToolTooltip": "Execute `git mergetool`.", "Refresh": "Atualizar", "RefreshTooltip": "Atualize o estado do git (ou seja, execute `git status`, `git branch`, etc em segundo plano para atualizar o conteúdo de painéis). Isso não executa `git fetch`.", "Push": "Empurre (Push)", "Pull": "Puxar (Pull)", "PushTooltip": "Faça push do branch atual para o seu branch upstream. Se nenhum upstream estiver configurado, você será solicitado a configurar um branch a montante.", "PullTooltip": "Puxe alterações do controle remoto para o ramo atual. Se nenhum upstream estiver configurado, será solicitado configurar um ramo a montante.", "Scroll": "Navegar", "FileFilter": "Filtrar arquivos por status", "CopyFileName": "Nome do arquivo", "CopyFileDiffTooltip": "Se existirem itens preparados, este comando considera apenas eles", "CopySelectedDiff": "Diferença do arquivo selecionado", "CopyAllFilesDiff": "Diferença de todos os arquivos", "NoContentToCopyError": "Nada para copiar", "FileNameCopiedToast": "No do arquivo copiado para a área de transferência", "FilePathCopiedToast": "Caminho do arquivo copiado para a área de transferência", "FileDiffCopiedToast": "Diferença do arquivo copiado para a área de transferência ", "AllFilesDiffCopiedToast": "Todos os arquivos diferentes foram copiados para a área de transferência", "FilterStagedFiles": "Mostrar somente os arquivos em staging", "FilterUnstagedFiles": "Mostrar somente arquivos que não estão em staging", "FilterTrackedFiles": "Mostrar apenas arquivos rastreados", "FilterUntrackedFiles": "Mostrar apenas arquivos não rastreados", "NoFilter": "Sem filtros", "FilterLabelStagedFiles": "(somente preparado)", "FilterLabelUnstagedFiles": "(apenas unstaged)", "FilterLabelTrackedFiles": "(somente rastreado)", "FilterLabelUntrackedFiles": "(somente não acompanhado)", "FilterLabelConflictingFiles": "(apenas conflitando)", "MergeConflictsTitle": "Mesclar conflitos", "Checkout": "Verificar", "CheckoutTooltip": "Checar item selecionado", "CantCheckoutBranchWhilePulling": "Você não pode hecar outra branch enquanto puxa a branch atual", "TagCheckoutTooltip": "Checar a tag selecionada como um HEAD, desanexado", "RemoteBranchCheckoutTooltip": "Checar a nova branch baseada na brach remota selecionada, ou a branch remota como HEAD, desanexado", "CantPullOrPushSameBranchTwice": "Você não pode empurar ou puxar uma branch enquanto ele já está a ser puxado ou empurrado", "NoChangedFiles": "Arquivos não alterados", "SoftReset": "Reiniciar suave", "AlreadyCheckedOutBranch": "Você já tem uma checagem dessa branch", "SureForceCheckout": "Você está certo de que quer forçar uma checagem? Você perdera todos as mudanças locais", "ForceCheckoutBranch": "Forçar checagem de branch", "BranchName": "Nome da Branch", "NewBranchNameBranchOff": "Novo nome da branch (branch está fora de '{{.branchName}}')", "CantDeleteCheckOutBranch": "Você não pode excluir a branch checada", "DeleteBranchTitle": "Deletar branch '{{.selectedBranchName}}'?", "DeleteBranchesTitle": "Excluir as branches selecionadas?", "DeleteLocalBranch": "Deletar branch local", "DeleteLocalBranches": "Deletar branches locais", "DeleteRemoteBranchOption": "Deletar branch remota", "DeleteRemoteBranchPrompt": "Você está certo de que quer deletar a branch remota '{{.selectedBranchName}}' de '{{.upstream}}'?", "DeleteRemoteBranchesPrompt": "Tem certeza de que deseja excluir as ramificações remotas selecionadas dos seus respectivos remotos?", "DeleteLocalAndRemoteBranchPrompt": "Tem certeza que quer excluir ambos '{{.localBranchName}}' da sua máquina, e '{{.remoteBranchName}}' de '{{.remoteName}}'?", "DeleteLocalAndRemoteBranchesPrompt": "Tem certeza de que deseja excluir as ramificações selecionadas da sua máquina, e suas ramificações remotas de seus respectivos remotos?", "ForceDeleteBranchTitle": "Forçar deleção de branch", "ForceDeleteBranchMessage": "{{.selectedBranchName}} não está completamente mesclada. Você está certo que quer deletar ela?", "ForceDeleteBranchesMessage": "Algumas das filiais selecionadas não são totalmente mescladas. Tem certeza que deseja excluí-las?", "RebaseBranch": "Refazer", "RebaseBranchTooltip": "Refazer a branch checada na branch selecionada", "CantRebaseOntoSelf": "Você não pode refazer a branch nela mesma", "CantMergeBranchIntoItself": "Você não pode mesclar a branch em si mesmo", "ForceCheckout": "Forçar checagem", "ForceCheckoutTooltip": "Forçar checagem da branch selecionada. Isso irá descartar todas as mudanças no seu diretório de trabalho antes cheque a branch selecionada ", "CheckoutByName": "Checar por nome", "CheckoutByNameTooltip": "Checar por nome. Na caixa de entrada você pode inserir '-' para trocar para a última branch ", "RemoteBranchCheckoutTitle": "Checar {{.branchName}}", "RemoteBranchCheckoutPrompt": "Como você gostaria de checar essa branch?", "CheckoutTypeNewBranch": "Nova branch local", "CheckoutTypeNewBranchTooltip": "Checar a branch remota como a branch local, rastreando a branch remota", "CheckoutTypeDetachedHead": "HEAD desanexado", "CheckoutTypeDetachedHeadTooltip": "Checar a branch remota como um HEAD desanexado, que pode ser útil se você apenas quer para testar a branch, mas não trabalha nela você mesmo. Você ainda pode criar um branch remoto a partir dela depois", "NewBranch": "Nova branch", "NewBranchFromStashTooltip": "Criar um novo ramo a partir da entrada de lixo selecionada. Isso funciona verificando o commit do qual a entrada de lixo foi criada, criar um novo branch a partir desse commit e, em seguida, aplicar a entrada de lixo ao novo branch como um commit adicional.", "NoBranchesThisRepo": "Nenhuma branch para esse repositório", "CommitWithoutMessageErr": "Você não pode dar commit sem uma mensagem de commit", "Close": "Fechar", "CloseCancel": "Fechar/Cancelar", "Confirm": "Confirmar", "Quit": "Sair", "SquashTooltip": "Squash o commit selecionado no commit abaixo dele. A mensagem do commit selecionado será anexada ao commit abaixo dele.", "CannotSquashOrFixupFirstCommit": "Não há commit abaixo para squash em", "CannotSquashOrFixupMergeCommit": "Não é possível squash ou corrigir um commit de merge", "Fixup": "Fixup", "FixupTooltip": "Faça o commit selecionado no commit abaixo dele. Semelhante para o squash, mas a mensagem do commit selecionado será descartada.", "SureFixupThisCommit": "Tem certeza que deseja 'corrigir' o(s) commit(s) selecionado(s) no commit abaixo?", "SureSquashThisCommit": "Tem certeza que deseja esmagar o(s) commit(s) selecionado(s) no commit abaixo?", "Squash": "Squash", "SquashMerge": "Mesclar Squash", "PickCommitTooltip": "Marque o commit selecionado para ser escolhido (quando meados da base). Isso significa que o commit será mantido ao continuar o rebase.", "Pick": "Escolher", "CantPickDisabledReason": "Não é possível escolher um commit quando não estiver no centro da rebase", "Edit": "Editar", "RevertCommit": "Reverter commit", "Revert": "Reverter", "RevertCommitTooltip": "Crie um commit reverter para o commit selecionado, que aplica as alterações do commit selecionado em reverso.", "Reword": "Reword", "CommitRewordTooltip": "Repetir a mensagem de submissão selecionada.", "DropCommit": "Descartar", "DropCommitTooltip": "Solte o commit selecionado. Isso irá remover o commit do branch através de uma rebase. Se o commit faz com que as alterações em commits posteriores dependem, você pode precisar resolver conflitos de merge.", "MoveDownCommit": "Mover commit um para baixo", "MoveUpCommit": "Mover o commit um para cima", "CannotMoveAnyFurther": "Não é possível mover mais", "CannotMoveMergeCommit": "Não é possível mover commit de um merge", "EditCommit": "Editar (iniciar rebase interativa)", "EditCommitTooltip": "Editar o commit selecionado. Use isto para iniciar uma rebase interativa a partir do commit selecionado. Quando já estiver no meio da reconstrução, isto irá marcar o commit selecionado para edição, o que significa que ao continuar com a reformulação. a rebase irá pausar no commit selecionado para permitir que você faça alterações.", "AmendCommitTooltip": "Alterar o commit com mudanças em sted. Se o commit selecionado for o commit HEAD, ele executará o `git commit --amend`. Caso contrário, o compromisso será alterado por meio de uma base de apoio.", "Amend": "Modificar", "ResetAuthor": "Redefinir autor", "ResetAuthorTooltip": "Redefinir o autor do commit para o usuário atualmente configurado. Isto também irá renovar o timestamp do autor", "SetAuthor": "Definir autor", "SetAuthorTooltip": "Definir o autor baseado em um prompt", "AddCoAuthor": "Adicionar co-autor", "AmendCommitAttribute": "Alterar atributo de commit", "AmendCommitAttributeTooltip": "Definir/Redefinir autor de submissão ou co-autor definido.", "SetAuthorPromptTitle": "Configura autor (deve se parecer com 'Nome ')", "AddCoAuthorPromptTitle": "Adicionar co-autor (deve se parecer com 'Nome ')", "AddCoAuthorTooltip": "Adicione um coautor usando o Github/Gitlab metadata co-produzido por.", "SureResetCommitAuthor": "O campo autor deste commit será atualizado para corresponder ao usuário configurado. Isso também renova o timestamp do autor. Continuar?", "RewordCommitEditor": "Republicar com o editor", "NoCommitsThisBranch": "Não há commits para este branch", "UpdateRefHere": "Atualizar branch '{{.ref}}' aqui", "ExecCommandHere": "Execute o seguinte comando aqui:", "Error": "Erro", "Undo": "Desfazer", "UndoReflog": "Desfazer", "RedoReflog": "Refazer", "UndoTooltip": "O reflog será usado para determinar qual comando git para executar para desfazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração.", "RedoTooltip": "O reflog será usado para determinar qual comando git para executar para refazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração.", "UndoMergeResolveTooltip": "Desfazer resolução de conflitos de última mesclagem.", "DiscardAllTooltip": "Descartar mudanças agendadas e não preparadas em '{{.path}}'.", "DiscardUnstagedTooltip": "Descartar mudanças não preparadas em '{{.path}}'.", "DiscardUnstagedDisabled": "Os itens selecionados não possuem mudanças staging e não preparados.", "Pop": "Pop", "StashPopTooltip": "Aplique a entrada de stash no seu diretório de trabalho e remova a entrada de stash.", "Drop": "Descartar", "StashDropTooltip": "Remova a entrada do stash da lista de armazenamento.", "Apply": "Aplicar", "StashApplyTooltip": "Aplique o stash no seu diretório de trabalho.", "NoStashEntries": "Sem itens no stash", "StashDrop": "Remover stash", "StashPop": "Remover Stash ", "SurePopStashEntry": "Tem certeza de que deseja exibir esta entrada de stash?", "StashApply": "Aplica o Stash", "SureApplyStashEntry": "Tem certeza que deseja aplicar esta entrada de stash?", "NoTrackedStagedFilesStash": "Você não tem arquivos rastreados/staging para armazenar", "NoFilesToStash": "Você não tem arquivos para armazenar", "StashChanges": "Alterações preparadas", "RenameStash": "Renomear o stasj", "RenameStashPrompt": "Renomear o estoque: {{.stashName}}", "OpenConfig": "Abrir o ficheiro de config", "EditConfig": "Editar arquivo de configuração", "ForcePush": "Forçar push", "ForcePushPrompt": "Seu branch divergiu do branch remoto. Pressione {{.cancelKey}} para cancelar, ou {{.confirmKey}} para forçar a push.", "ForcePushDisabled": "Seu branch divergiu do branch remoto e você desabilitou o push forçado forçado", "UpdatesRejected": "Atualizações foram rejeitadas. Por favor, busque e examine as alterações remotas antes de enviar novamente.", "UpdatesRejectedAndForcePushDisabled": "Atualizações foram rejeitadas e você desativou o push de força", "CheckForUpdate": "Verificar atualização", "CheckingForUpdates": "A verificar por actualização…", "UpdateAvailableTitle": "Atualização disponível!", "UpdateAvailable": "Baixar e instalar a versão {{.newVersion}}?", "UpdateInProgressWaitingStatus": "Atualizando", "UpdateCompletedTitle": "Atualização concluída!", "UpdateCompleted": "A atualização foi instalada com sucesso. Reinicie o lazygit para que tenha efeito.", "FailedToRetrieveLatestVersionErr": "Falha ao recuperar informações da versão", "OnLatestVersionErr": "Você já tem a versão mais recente!", "MajorVersionErr": "Nova versão ({{.newVersion}}) tem mudanças não compatíveis com as versões anteriores comparadas com a versão atual ({{.currentVersion}})", "CouldNotFindBinaryErr": "Não foi possível encontrar nenhum binário em {{.url}}", "UpdateFailedErr": "Falha na atualização: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "Atualmente atualizando", "ConfirmQuitDuringUpdate": "Uma atualização está em andamento. Tem certeza que deseja sair?", "MergeToolTitle": "Ferramenta de mesclagem", "MergeToolPrompt": "Tem certeza de que deseja abrir o `git mergetool`?", "IntroPopupMessage": "\nObrigado por usar lazygit! Sério, você rock. Três coisas para compartilhar com você:\n\n 1) Se quiser aprender sobre os recursos do lazygit, assista a este vid:\n https://youtu. e/CPLdltN7wgE\n\n 2) Não deixe de ler as últimas notas de lançamento em:\n https://github. um/jesseduffield/lazygit/releases\n\n 3) Se você estiver usando um git, isso o torna um programador! Com a sua ajuda, podemos fazer\n lazygit melhor, então considere se tornar um colaborador e se junte à diversão no\n https://github. om/jesseduffield/lazygit\n Você também pode me patrocinar e me dizer no que trabalhar clicando no botão\n de doar na parte inferior direita.\n Ou até mesmo apenas adicionar estrela no repositório para compartilhar o amor!\n", "DeprecatedEditConfigWarning": "\n### Aviso de configuração obsoleto ###\n\nAs seguintes configurações são descontinuadas e serão removidas em uma futura versão\ndo A:\n{{configs}}\n\nPor favor, consulte\n\n https://github. om/jesseduffield/lazygit/blob/master/docs/Config.md#configuring-file-editor\n\npara informações atualizadas como configurar seu editor.\n\n", "NonReloadableConfigWarningTitle": "Configuração alterada", "NonReloadableConfigWarning": "As seguintes configurações foram alteradas, mas a mudança não tem efeito imediatamente. Encerre e reinicie o lazygit para que as mudanças tenham efeito:\n\n{{configs}}", "GitconfigParseErr": "Gogit falhou ao analisar seu arquivo gitconfig devido à presença de caracteres '\\' não citados. Removendo-os deve corrigir o problema.", "EditFile": "Editar arquivo", "EditFileTooltip": "Abrir arquivo no editor externo.", "OpenFile": "Abrir arquivo", "OpenFileTooltip": "Abrir arquivo no aplicativo padrão.", "OpenInEditor": "Abrir no editor", "IgnoreFile": "Adicionar ao .gitignore", "ExcludeFile": "Adicionar ao .git/info/exclui", "RefreshFiles": "Atualizar arquivos", "Merge": "Mesclar", "RegularMerge": "Mesclagem regular", "MergeBranchTooltip": "Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash)", "ConfirmQuit": "Tem a certeza de que pretende sair?", "SwitchRepo": "Mudar para um repositório recente", "AllBranchesLogGraph": "Mostrar/ciclo todos os logs de filiais", "UnsupportedGitService": "Serviço git não suportado", "CopyPullRequestURL": "Copiar URL do pull request para área de transferência", "NoBranchOnRemote": "Este branch não existe no remoto. Primeiro, você precisa fazer push para o remoto.", "Fetch": "Buscar", "FetchTooltip": "Buscar alterações do controle remoto.", "CollapseAll": "Recolher todos os arquivos", "CollapseAllTooltip": "Recolher todos os diretórios na árvore de arquivos", "ExpandAll": "Expandir todos os arquivos", "ExpandAllTooltip": "Expandir todos os diretórios na árvore do arquivo", "DisabledInFlatView": "Não disponível na vista plana", "FileEnter": "Stage lines / Colapso diretório", "FileEnterTooltip": "Se o item selecionado for um arquivo, o foco na exibição de preparo para o estágio de cenas/linhas individuais. Se o item selecionado for um diretório, recolher/expandi-lo.", "FileStagingRequirements": "Só pode stage linhas individuais para arquivos rastreados", "StageSelectionTooltip": "Ativar/desativar seleção em staged/unstaged", "DiscardSelection": "Descartar", "DiscardSelectionTooltip": "Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança.", "ToggleSelectHunk": "Selecione o local", "ToggleSelectHunkTooltip": "Ativa/desativa modo seleção de hunk ", "ToggleSelectionForPatch": "Alternar linhas no caminho", "EditHunk": "Editar hunk", "EditHunkTooltip": "Editar o local selecionado no editor externo.", "ToggleStagingView": "Mudar de visão", "ToggleStagingViewTooltip": "Alternar para outra visão (staged/não processadas alterações).", "ReturnToFilesPanel": "Retornar ao painel de arquivos", "FastForward": "Avanço rápido", "FastForwardTooltip": "Encaminhamento rápido de branch selecionada a partir do upstream.", "FastForwarding": "Encaminhamento rápido", "FoundConflictsTitle": "Conflitos!", "ViewConflictsMenuItem": "Visualizar conflitos", "AbortMenuItem": "Abortar %s", "PickHunk": "Escolha o local", "PickAllHunks": "Pegar todos os pedaços", "ViewMergeRebaseOptions": "Ver opções de mesclar/rebase", "ViewMergeRebaseOptionsTooltip": "Ver opções para abortar/continuar/pular o merge/rebase atual.", "ViewMergeOptions": "Visualizar opções de merge", "ViewRebaseOptions": "Ver opções de rebase", "NotMergingOrRebasing": "Você não está atualmente nem rebasing nem mesclando", "AlreadyRebasing": "Não é possível executar esta ação durante uma rebase", "RecentRepos": "Repositórios recentes", "MergeOptionsTitle": "Opções de mesclagem", "RebaseOptionsTitle": "Opções de rebase", "CommitSummaryTitle": "Sumário do commit", "CommitDescriptionTitle": "Descrição de Commit", "CommitDescriptionSubTitle": "Pressione {{.togglePanelKeyBinding}} para alternar o foco, {{.commitMenuKeybinding}} para abrir o menu", "CommitDescriptionFooter": "Pressione {{.confirmInEditorKeybinding}} para confirmar", "LocalBranchesTitle": "Branches locais", "SearchTitle": "Procurar", "TagsTitle": "Etiquetas", "MenuTitle": "Menu", "CommitMenuTitle": "Menu de Commit", "RemotesTitle": "Remotes", "RemoteBranchesTitle": "Branches remotos", "PatchBuildingTitle": "Painel principal (patch build)", "InformationTitle": "Informações", "SecondaryTitle": "Secundário", "ReflogCommitsTitle": "Reflog", "ConflictsResolved": "Todos os conflitos de merge resolvidos. Continuar?", "Continue": "Continuar", "UnstagedFilesAfterConflictsResolved": "Os arquivos foram modificados desde que os conflitos foram resolvidos. Auto-encapsulá-los e continuar?", "RebasingTitle": "Rebase '{{.checkedOutBranch}}'", "RebasingFromBaseCommitTitle": "Rebase '{{.checkedOutBranch}}' de uma base marcada", "SimpleRebase": "Rebase simples para '{{.ref}}'", "InteractiveRebase": "Rebase interativa em '{{.ref}}'", "RebaseOntoBaseBranch": "Rebase no ramo base ({{.baseBranch}})", "InteractiveRebaseTooltip": "Comece uma rebase interativa com uma pausa no início, então você pode atualizar os commits TODO antes de continuar.", "RebaseOntoBaseBranchTooltip": "Rebase o branch check-out em seu ramo base (ou seja, o ramo principal mais próximo).", "MustSelectTodoCommits": "Ao rebaste, esta ação só funciona numa seleção de commits do TODO.", "FwdNoUpstream": "Não é possível encaminhar rapidamente um branch sem upstream", "FwdNoLocalUpstream": "Não é possível encaminhar rapidamente um branch cujo controle remoto não está registrado localmente", "FwdCommitsToPush": "Não é possível encaminhar um branch com commits para fazer push", "PullRequestNoUpstream": "Não é possível abrir uma pull request para um branch sem upstream", "ErrorOccurred": "Ocorreu um erro! Por favor, crie um problema em", "NoRoom": "Sala insuficiente", "YouAreHere": "VOCÊ ESTÁ AQUI", "YouDied": "VOCÊ MORREU!", "RewordNotSupported": "Reredacção de commits enquanto rebasing interativamente não é suportado atualmente", "ChangingThisActionIsNotAllowed": "Não é permitido alterar este tipo de rebase de tarefas", "DroppingMergeRequiresSingleSelection": "Soltar um commit de merge requer um único item selecionado", "CherryPickCopy": "Copiar (cherry-pick)", "CherryPickCopyTooltip": "Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `{{.paste}}` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `{{.escape}}` para cancelar a seleção.", "CherryPickCopyRangeTooltip": "Marcar commits como copiados do último commit copiado para o commit selecionado.", "PasteCommits": "Colar (cherry-pick)", "SureCherryPick": "Tem certeza que deseja cherry-pick o(s) commit(s) {{.numCommits}} copiado(s) para este branch?", "CherryPick": "cherry-pick", "CannotCherryPickNonCommit": "Não é possível escolher este tipo de item de tarefa", "CannotCherryPickMergeCommit": "Commits de merge Cherry-picking não são suportados", "Donate": "Doar", "AskQuestion": "Faça perguntas", "PrevLine": "Selecione a linha anterior", "NextLine": "Selecionar próxima linha", "PrevHunk": "Ir para o local anterior", "NextHunk": "Ir para o próximo trecho", "PrevConflict": "Conflito anterior", "NextConflict": "Próximo conflito", "SelectPrevHunk": "Trecho anterior", "SelectNextHunk": "Próximo trecho", "ScrollDown": "Rolar para baixo", "ScrollUp": "Rolar para cima", "ScrollUpMainWindow": "Rolar janela principal para cima", "ScrollDownMainWindow": "Rolar a janela principal para baixo", "AmendCommitTitle": "Corrigir commit", "AmendCommitPrompt": "Tem certeza que deseja corrigir esse commit com seus arquivos encapsulados?", "AmendCommitWithConflictsMenuPrompt": "AVISO: está prestes a reemendar o seu último commit finalizado com conflitos resolvidos. Isso é muito improvável ser o que quer nesse ponto. Mais improvável você simplesmente querer continuar o rebase ao invés disso", "AmendCommitWithConflictsContinue": "Não, continuar a reconstrução", "AmendCommitWithConflictsAmend": "Sim, alterar o commit anterior", "DropCommitTitle": "Excluir commit", "DropCommitPrompt": "Tem certeza que deseja remover o(s) commit(s) selecionado(s)?", "DropUpdateRefPrompt": "Tem certeza que deseja excluir a(s) atualização(ões) selecionada(s)? Isso é irreversível, exceto abortando a rebase.", "DropMergeCommitPrompt": "Tem certeza que deseja remover o commit de mesclagem selecionado? Note que ele também vai apagar todos os commits que foram mesclados por ele.", "PullingStatus": "Puxando", "PushingStatus": "Enviando", "FetchingStatus": "Buscando", "SquashingStatus": "Esmagando", "FixingStatus": "Consertando", "DeletingStatus": "Deletando", "DroppingStatus": "Descartando", "MovingStatus": "Movendo", "RebasingStatus": "Rebase", "MergingStatus": "Mesclando", "LowercaseRebasingStatus": "recriando", "LowercaseMergingStatus": "mesclando", "AmendingStatus": "Modificação", "CherryPickingStatus": "Cherry-picking", "UndoingStatus": "Desfazendo", "RedoingStatus": "Refazer", "CheckingOutStatus": "Confira", "CommittingStatus": "Commitando", "RevertingStatus": "revertendo", "CreatingFixupCommitStatus": "Criando commit de correção", "CommitFiles": "Commit arquivos", "SubCommitsDynamicTitle": "Commits (%s)", "CommitFilesDynamicTitle": "Arquivos diff (%s)", "RemoteBranchesDynamicTitle": "Branches remotas (%s)", "ViewItemFiles": "Ver arquivos", "ViewItemFilesTooltip": "Visualizar os arquivos modificados pelo item selecionado.", "CommitFilesTitle": "Commit arquivos", "CheckoutCommitFileTooltip": "Arquivo de check-out. Isso substitui o arquivo em sua árvore de trabalho com a versão do commit selecionado.", "CanOnlyDiscardFromLocalCommits": "As alterações só podem ser descartadas de commits locais", "Remove": "Remover", "DiscardOldFileChangeTooltip": "Descartar as alterações desse commit para este arquivo. Isso executa uma rebase interativa em segundo plano, então você pode ter um conflito de merge se um commit posterior também alterar este arquivo.", "DiscardFileChangesTitle": "Descartar alterações de arquivo", "DiscardFileChangesPrompt": "Você tem certeza de que deseja remover as alterações do(s) arquivo(s) selecionado(s) deste commit?", "CreateRepo": "Não está em um repositório git. Criar um novo repositório git? (y/n): ", "BareRepo": "Você tentou abrir Lazygit em um repositório puro, mas Lazygit ainda não suporta repositórios vazios. Abrir os repositórios mais recentes? (y/n) ", "InitialBranch": "Nome da branch? (deixe vazio para o padrão do git): ", "NoRecentRepositories": "É necessário abrir lazygit em um repositório git. Nenhum repositório recente válido. Saindo do sistema.", "IncorrectNotARepository": "O valor de 'notARepository' está incorreto. Deve ser um dos 'prompt', 'create', 'sk', ou 'quit'.", "AutoStashTitle": "Autoarmazenar?", "AutoStashPrompt": "Você deve esconder e mostrar suas alterações para que elas passem. Quer fazer isso automaticamente? (enter/esc)", "StashPrefix": "Alterações de carimbo automático para ", "Discard": "Descartar", "DiscardChangesTitle": "Descartar alterações", "DiscardFileChangesTooltip": "Exibir opções para descartar alterações para o arquivo selecionado.", "Cancel": "Cancelar", "DiscardAllChanges": "Descartar todas as Alterações", "DiscardUnstagedChanges": "Descartar mudanças não preparadas", "DiscardAllChangesToAllFiles": "Apargar árvore ativa", "DiscardAnyUnstagedChanges": "Descartar mudanças não preparadas", "DiscardUntrackedFiles": "Descartar arquivos não monitorizados", "DiscardStagedChanges": "Descartar mudanças em Staging", "HardReset": "reinicialização total", "BranchDeleteTooltip": "Ver opções de exclusão para a branch local/remoto.", "TagDeleteTooltip": "Ver opções de exclusão para tag local/remoto.", "Delete": "Apagar", "Reset": "Restaurar", "ResetTooltip": "Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado.", "ViewResetOptions": "Restaurar", "FileResetOptionsTooltip": "Opções de redefinição de exibição para árvore de trabalho (por exemplo, nukando a árvore de trabalho).", "CreateFixupCommit": "Criar commit de correção", "CreateFixupCommitTooltip": "Crie o commit 'correção!' para o commit selecionado. Mais tarde, você pode pressionar `{{.squashAbove}}` neste mesmo commit para aplicar todas os commits de correção acima.", "CreateAmendCommit": "Criar \"alterar!\" commit", "FixupMenu_Fixup": "corrigir! commit", "FixupMenu_FixupTooltip": "Permite que você arrume outro commit e mantenha a mensagem do commit original.", "FixupMenu_AmendWithChanges": "alterar! commit com mudanças", "FixupMenu_AmendWithChangesTooltip": "Permite que você corrija outro commit e também altere sua mensagem de commit.", "FixupMenu_AmendWithoutChanges": "alterar! commit sem alterações (pura reformulação)", "FixupMenu_AmendWithoutChangesTooltip": "Permite alterar a mensagem de commit de outro commit sem alterar seu conteúdo.", "SquashAboveCommitsTooltip": "Aplicar Squash all 'correção!', seja acima do commit selecionado, ou tudo no branch atual (autosquash).", "SquashCommitsAboveSelectedTooltip": "Squash todos 'fixup!' commits acima do commit selecionado (autosquash).", "SquashCommitsInCurrentBranchTooltip": "Squash all 'fixup!' commits no branch atual (autosquash).", "SquashAboveCommits": "Aplicar commits de correções", "SquashCommitsInCurrentBranch": "No branch atual", "SquashCommitsAboveSelectedCommit": "Acima do commit escolhido", "CannotSquashCommitsInCurrentBranch": "Não é possível realizar o squash commits no branch atual: o commit do HEAD é um commit de merge ou está presente no branch principal.", "ExecuteShellCommand": "Executar comando da shell", "ExecuteShellCommandTooltip": "Traga um prompt onde você pode digitar um comando shell para executar.", "ShellCommand": "Comando Shell:", "CommitChangesWithoutHook": "Fazer commit de alterações sem pré-commit", "ResetTo": "Redefinir para", "ResetSoftTooltip": "Redefinir o HEAD para o commit escolhido, e manter as alterações entre o commit atual e o commit escolhido à medida que as mudanças forem processadas.", "ResetMixedTooltip": "Redefinir o HEAD para o commit escolhido e manter as alterações entre o commit atual e escolhido conforme mudanças não preparadas.", "ResetHardTooltip": "Redefinir HEAD para o commit escolhido e descartar todas as alterações entre o atual e o commit escolhido, bem como todas as modificações actuais na árvore de trabalho.", "PressEnterToReturn": "Pressione Enter para retornar ao lazygit", "ViewStashOptions": "Ver opções de stash", "ViewStashOptionsTooltip": "Ver opções de stash (por exemplo, trash all, stash staged, stash unsttued).", "Stash": "Stash", "StashTooltip": "Stash todas as alterações. Para outras variações de armazenamento, use a fixação de teclas de armazenamento.", "StashAllChanges": "Alterações preparadas", "StashStagedChanges": "Alterações não preparadas", "StashAllChangesKeepIndex": "Guardar todas as alterações e manter índice", "StashUnstagedChanges": "Stash todas as mudanças não preparadas", "StashIncludeUntrackedChanges": "Stash todas as alterações, incluindo arquivos não rastreados", "StashOptions": "Opções de Stash", "NotARepository": "Erro: deve ser executado dentro de um repositório git", "WorkingDirectoryDoesNotExist": "Erro: o diretório de trabalho atual não existe", "Jump": "Pular para o painel", "ScrollLeftRight": "Rolar esquerda/direita", "ScrollLeft": "Rolar à esquerda", "ScrollRight": "Scroll para a direita", "DiscardPatch": "Descartar patch", "DiscardPatchConfirm": "Você só pode construir um patch de um commit/stash-entry por vez. Descartar o patch atual?", "CantPatchWhileRebasingError": "Você não pode construir um patch ou executar comandos de patch enquanto estiver em estado de fusão ou de recriação", "ToggleAddToPatch": "Alternar entre o arquivo incluído no patch", "ToggleAddToPatchTooltip": "Alternar se o arquivo está incluído no patch personalizado. Veja {{.doc}}.", "ToggleAllInPatch": "Alternar todos os arquivos", "ToggleAllInPatchTooltip": "Adicionar/remover todos os arquivos de commit para atualização personalizada. Consulte {{.doc}}.", "UpdatingPatch": "Atualizando patch", "ViewPatchOptions": "Ver opções de patch personalizadas", "PatchOptionsTitle": "Opções de modificação", "NoPatchError": "Nenhum patch criado ainda. Para começar a construir um patch, use 'space' em um arquivo de commit ou insira para adicionar linhas específicas", "EmptyPatchError": "O patch ainda está vazio. Adicione alguns arquivos ou linhas ao seu patch primeiro.", "EnterCommitFile": "Insira o arquivo / Alternar diretório recolhido", "EnterCommitFileTooltip": "Se um arquivo estiver selecionado, insira o arquivo para que você possa adicionar/remover linhas individuais no patch personalizado. Se um diretório for selecionado, ative o diretório.", "ExitCustomPatchBuilder": "Sair do construtor de patch personalizado", "EnterUpstream": "Insira o upstream como ' '", "InvalidUpstream": "Upstream inválido. Deve estar no formato ' '", "ReturnToRemotesList": "Retornar à lista de controles remotos", "NewRemote": "Novo controle", "NewRemoteName": "Novo nome do controle remoto:", "NewRemoteUrl": "Nova URL remota:", "ViewBranches": "Ver branches", "EditRemoteName": "Digite o nome remoto atualizado para {{.remoteName}}:", "EditRemoteUrl": "Digite a URL remota atualizada para {{.remoteName}}:", "RemoveRemote": "Remover remoto", "RemoveRemoteTooltip": "Remover o controle remoto. Quaisquer ramificações locais de rastreamento de um ramo remoto do controle não serão afetadas.", "RemoveRemotePrompt": "Tem certeza que deseja remover o controle remoto?", "DeleteRemoteBranch": "Deletar branch remota", "DeleteRemoteBranches": "Deletar branch remota", "DeleteRemoteBranchTooltip": "Excluir o branch remoto do controle remoto.", "DeleteLocalAndRemoteBranch": "Excluir ramo local e remoto", "DeleteLocalAndRemoteBranches": "Excluir branches locais e remotos", "SetAsUpstream": "Definir como upstream", "SetAsUpstreamTooltip": "Definir o ramo remoto selecionado como fluxo do branch check-out.", "SetUpstream": "Configurar o upstream da filial selecionada", "UnsetUpstream": "Desconfigurar upstream do branch selecionado", "ViewDivergenceFromUpstream": "Ver divergência do upstream", "ViewDivergenceFromBaseBranch": "Ver diferenças em relação ao ramo base ({{.baseBranch}})", "CouldNotDetermineBaseBranch": "Não foi possível determinar o branch base", "DivergenceSectionHeaderLocal": "Local", "DivergenceSectionHeaderRemote": "Remoto", "ViewUpstreamResetOptions": "Redefinir ramo check-out para {{.upstream}}", "ViewUpstreamResetOptionsTooltip": "Exibir opções para redefinir o branch check-out no {{upstream}}. Nota: isso não irá redefinir o ramo selecionado para a montante, ele irá redefinir o ramo de verificação para a parte a montante.", "ViewUpstreamRebaseOptions": "Rebase ramo check-out na {{.upstream}}", "ViewUpstreamRebaseOptionsTooltip": "Ver opções para rectificar o branch check-out no {{upstream}}. Nota: isso não irá rebasear o branch selecionado no plano a montante, ele irá rebasear o branch de check-out no fluxo a montante.", "UpstreamGenericName": "upstream da branch selecionada", "Actions": {}, "Bisect": {}, "Log": {}, "BreakingChangesByVersion": {} } lazygit-0.50.0+ds1/pkg/i18n/translations/ru.json000066400000000000000000001563421500612110400213450ustar00rootroot00000000000000{ "NotEnoughSpace": "Недостаточно места для отрисовки панелей", "DiffTitle": "Сравнения", "FilesTitle": "Файлы", "BranchesTitle": "Ветки", "CommitsTitle": "Коммиты", "StashTitle": "Хранилище", "SnakeTitle": "Змейка", "EasterEgg": "Пасхалка", "UnstagedChanges": "Непроиндексированные Изменения", "StagedChanges": "Проиндексированные Изменения", "MainTitle": "Главная", "StagingTitle": "Главная панель (Индексирование)", "MergingTitle": "Главная панель (Слияние)", "NormalTitle": "Главная панель (Обычный)", "LogTitle": "Журнал", "CommitSummary": "Сводка коммита", "CredentialsUsername": "Имя пользователя", "CredentialsPassword": "Пароль", "CredentialsPassphrase": "Введите пароль для SSH ключа", "CredentialsPIN": "Введите PIN-код для SSH ключа", "PassUnameWrong": "Неверный пароль, кодовая фраза и/или имя пользователя", "Commit": "Сохранить изменения", "AmendLastCommit": "Правка последнего коммита", "AmendLastCommitTitle": "Правка Последнего Коммита", "SureToAmend": "Вы уверены, что хотите править последний коммит? Впоследствии можно изменить сообщение коммита на панели коммитов.", "NoCommitToAmend": "Не найден коммит для внесения поправок.", "CommitChangesWithEditor": "Сохранить изменения с помощью редактора git", "StatusTitle": "Статус", "GlobalTitle": "Глобальные сочетания клавиш", "Menu": "Меню", "Execute": "Выполнить", "Stage": "Переключить индекс", "ToggleStagedAll": "Все проиндексированные/непроиндексированные", "ToggleTreeView": "Переключить вид дерева файлов", "OpenMergeTool": "Открыть внешний инструмент слияния (git mergetool)", "Refresh": "Обновить", "Push": "Отправить изменения", "Pull": "Получить и слить изменения", "Scroll": "Прокрутить", "FileFilter": "Фильтровать файлы (проиндексированные/непроиндексированные)", "FilterStagedFiles": "Показывать только проиндексированные файлы", "FilterUnstagedFiles": "Показывать только непроиндексированные файлы", "MergeConflictsTitle": "Конфликты Слияния", "Checkout": "Переключить", "NoChangedFiles": "Нет изменённых файлов", "SoftReset": "Мягкий сброс", "AlreadyCheckedOutBranch": "Вы уже переключились в эту ветку", "SureForceCheckout": "Вы уверены, что хотите принудительная переключить? Вы потеряете все локальные изменения", "ForceCheckoutBranch": "Принудительное Переключение Ветки", "BranchName": "Название ветки", "NewBranchNameBranchOff": "Название новой ветки (Ветка с '{{.branchName}}')", "CantDeleteCheckOutBranch": "Невозможно удалить переключённую ветку!", "ForceDeleteBranchMessage": "'{{.selectedBranchName}}' не полностью слилась. Вы уверены, что хотите удалить его?", "RebaseBranch": "Перебазировать переключённую ветку на эту ветку", "CantRebaseOntoSelf": "Невозможно перебазировать ветку на себя", "CantMergeBranchIntoItself": "Невозможно объединить ветку в себя", "ForceCheckout": "Принудительное переключение", "CheckoutByName": "Переключить по названию", "NewBranch": "Новая ветка", "NoBranchesThisRepo": "Нет веток для этого репозитория", "CommitWithoutMessageErr": "Вы не можете сохранить изменения без сообщения коммита", "Close": "Закрыть", "CloseCancel": "Закрыть/отменить", "Confirm": "Подтвердить", "Quit": "Выйти", "CannotSquashOrFixupFirstCommit": "Ниже нет коммита, который можно было бы объединить", "Fixup": "Объединить несколько коммитов в один отбросив сообщение коммита (Fixup) ", "SureFixupThisCommit": "Вы уверены, что хотите объединить несколько коммитов, отбросив сообщение коммита? Он будет объединён с коммитом ниже", "SureSquashThisCommit": "Вы уверены, что хотите объединить несколько коммитов в нижний коммит?", "Squash": "Объединить коммиты (Squash)", "PickCommitTooltip": "Выбрать коммит (в середине перебазирования)", "RevertCommit": "Отменить коммит", "Reword": "Перефразировать коммит", "DropCommit": "Удалить коммит", "MoveDownCommit": "Переместить коммит вниз на один", "MoveUpCommit": "Переместить коммит вверх на один", "EditCommitTooltip": "Изменить коммит", "AmendCommitTooltip": "Править последний коммит с проиндексированными изменениями", "ResetAuthor": "Сброс автора коммита", "SetAuthor": "Установить автора", "AmendCommitAttribute": "Установить/убрать автора коммита", "SetAuthorPromptTitle": "Установить автора (должно выглядеть как «Имя »)", "SureResetCommitAuthor": "Поле автора этого автора будет обновлено в соответствии с настроенным пользователем. Это также обновляет временную метку автора. Продолжить?", "RewordCommitEditor": "Переписать коммит с помощью редактора", "NoCommitsThisBranch": "Нет коммитов для этой ветки", "UpdateRefHere": "Обновить ветку '{{.ref}}' здесь", "Error": "Ошибка", "Undo": "Отменить", "UndoReflog": "Отменить (через reflog) (экспериментальный)", "RedoReflog": "Повторить (через reflog) (экспериментальный)", "UndoTooltip": "Журнал ссылок (reflog) будет использоваться для определения того, какую команду git запустить, чтобы отменить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты.", "RedoTooltip": "Журнал ссылок (reflog) будет использоваться для определения того, какую команду git нужно запустить, чтобы повторить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты.", "DiscardAllTooltip": "Отменить проиндексированные и непроиндексированные изменения в '{{.path}}'.", "DiscardUnstagedTooltip": "Отменить непроиндексированные изменения в '{{.path}}'.", "Pop": "Применить припрятанные изменения и тут же удалить их из хранилища", "Drop": "Удалить припрятанные изменения из хранилища", "Apply": "Применить припрятанные изменения", "NoStashEntries": "Нет записей в хранилище", "StashDrop": "Сбросить хранилище", "StashPop": "Применить припрятанные изменения и тут же удалить их из хранилища", "SurePopStashEntry": "Вы уверены, что хотите применить эти припрятанные изменения и тут же удалить их из хранилища?", "StashApply": "Применить припрятанные изменения", "SureApplyStashEntry": "Вы уверены, что хотите применить эти припрятанные изменения?", "NoTrackedStagedFilesStash": "У вас нет отслеженных/проиндексированных файлов для хранения", "NoFilesToStash": "У вас нет файлов для хранения", "StashChanges": "Припрятать изменения", "RenameStash": "Переименовать хранилище", "RenameStashPrompt": "Переименовать хранилище: {{.stashName}}", "OpenConfig": "Открыть файл конфигурации", "EditConfig": "Редактировать файл конфигурации", "ForcePush": "Принудительная отправка изменении", "ForcePushPrompt": "Ветка отклонилась от удалённой ветки. Нажмите «esc», чтобы отменить, или «enter», чтобы начать принудительную отправку изменении.", "ForcePushDisabled": "Ветка отклонилась от удалённой ветки. Принудительная отправка изменении была отключена", "UpdatesRejectedAndForcePushDisabled": "Обновления были отклонены. Принудительная отправка изменении была отключена", "CheckForUpdate": "Проверить обновления", "CheckingForUpdates": "Проверка обновлений...", "UpdateAvailableTitle": "Доступно обновление!", "UpdateAvailable": "Скачать и установить версию {{.newVersion}}?", "UpdateInProgressWaitingStatus": "Обновление", "UpdateCompletedTitle": "Обновление завершено!", "UpdateCompleted": "Обновление успешно установлено. Перезапустите lazygit, чтобы обновление вступило в силу.", "FailedToRetrieveLatestVersionErr": "Не удалось получить информацию о версии", "OnLatestVersionErr": "Установлена последняя версия", "MajorVersionErr": "Новая версия ({{.newVersion}}) содержит несовместимые с предыдущими версии изменения по сравнению с текущей версией ({{.currentVersion}})", "CouldNotFindBinaryErr": "Не удалось найти бинарный файл на {{.url}}", "UpdateFailedErr": "Не удалось обновить: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "Идёт Обновление", "ConfirmQuitDuringUpdate": "Выполняется обновление. Вы уверены, что хотите выйти?", "MergeToolTitle": "Инструмент слияния", "MergeToolPrompt": "Вы уверены, что хотите открыть `git mergetool`?", "IntroPopupMessage": "\nБлагодарю за использование lazygit! Серьёзно, вы просто супер. Три вещи, которыми я хочу поделиться:\n\n 1) Чтобы узнать о возможностях lazygit, посмотрите это видео:\n https://youtu.be/CPLdltN7wgE\n\n 2) Обязательно ознакомьтесь с последними примечаниями к выпуску перейдя по ссылке:\n https://github.com/jesseduffield/lazygit/releases\n\n 3) Используете git? Значит Вы программист! С Вашей помощью мы можем сделать lazygit лучше,\n станьте участником и присоединиться к веселью в\n https://github.com/jesseduffield/lazygit\n Вы также можете поддержать меня и рассказать, над чем мне ещё стоит поработать,\n нажав на кнопку \"Поддержать\" в правом нижнем углу.\n Или поделиться любовь просто добавив репозиторий в избранные.\n", "DeprecatedEditConfigWarning": "\n### Предупреждение об устаревшей конфигурации ###\n\nСледующие параметры конфигурации устарели и будут удалены в будущей\nверсии:\n{{configs}}\n\nПожалуйста, ознакомьтесь с\n\n https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#configuring-file-editing\n\nдля получения актуальной информации о том, как настроить ваш редактор.\n\n", "GitconfigParseErr": "Gogit не удалось проанализировать ваш файл gitconfig из-за наличия символов «\\» без кавычек. Их удаление должно решить проблему.", "EditFile": "Редактировать файл", "OpenFile": "Открыть файл", "IgnoreFile": "Добавить в .gitignore", "ExcludeFile": "Добавить в .git/info/exclude", "RefreshFiles": "Обновить файлы", "Merge": "Слияние с текущей переключённой веткой", "ConfirmQuit": "Вы уверены, что хотите выйти?", "SwitchRepo": "Переключиться на последний репозиторий", "UnsupportedGitService": "Неподдерживаемая служба git", "CopyPullRequestURL": "Скопировать URL запроса на принятие изменений в буфер обмена", "NoBranchOnRemote": "Этой ветки не существует в удалённом репозитории. Сначала вам нужно его отправить в удалённый репозитории.", "Fetch": "Получить изменения", "FileEnter": "Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога", "FileStagingRequirements": "Можно проиндексировать только отдельные строки для отслеживаемых файлов", "StageSelectionTooltip": "Переключить строку в проиндексированные / непроиндексированные", "DiscardSelection": "Отменить изменение (git reset)", "ToggleSelectHunk": "Переключить выборку частей", "ToggleSelectionForPatch": "Добавить/удалить строку(и) для патча", "EditHunk": "Изменить эту часть", "ToggleStagingView": "Переключиться на другую панель (проиндексированные/непроиндексированные изменения)", "ReturnToFilesPanel": "Вернуться к панели файлов", "FastForward": "Перемотать эту ветку вперёд из её upstream-ветки", "FastForwarding": "Получить изменения и перемотать вперёд", "FoundConflictsTitle": "Конфликты!", "ViewConflictsMenuItem": "Просмотр конфликтов", "AbortMenuItem": "Прервать %s", "PickHunk": "Выбрать эту часть", "PickAllHunks": "Выбрать все части", "ViewMergeRebaseOptions": "Просмотреть параметры слияния/перебазирования", "NotMergingOrRebasing": "В данный момент вы не выполняете ни перебазирования, ни слияние", "AlreadyRebasing": "Невозможно выполнить это действие во время перебазирования", "RecentRepos": "Последние репозитории", "MergeOptionsTitle": "Параметры слияния", "RebaseOptionsTitle": "Параметры перебазирования", "CommitSummaryTitle": "Сводка коммита", "CommitDescriptionTitle": "Описание коммита", "CommitDescriptionSubTitle": "Нажмите вкладку, чтобы переключить фокус", "LocalBranchesTitle": "Локальные Ветки", "SearchTitle": "Поиск", "TagsTitle": "Теги", "MenuTitle": "Меню", "RemotesTitle": "Удалённые репозитории", "RemoteBranchesTitle": "Удалённые ветки", "PatchBuildingTitle": "Главная панель (сборка патчей)", "InformationTitle": "Информация", "SecondaryTitle": "Вторичный", "ReflogCommitsTitle": "Журнал ссылок (Reflog)", "ConflictsResolved": "Все конфликты слияния разрешены. Продолжить?", "Continue": "Продолжить", "RebasingTitle": "Перебазировать '{{.checkedOutBranch}}'", "SimpleRebase": "Простая перебазировка на '{{.ref}}'", "InteractiveRebase": "Интерактивная перебазировка на '{{.ref}}'", "InteractiveRebaseTooltip": "Начать интерактивную перебазировку с перерыва в начале, чтобы можно было обновить TODO коммиты, прежде чем продолжить.", "FwdNoUpstream": "Невозможно перемотать ветку без upstream-ветки", "FwdNoLocalUpstream": "Невозможно перемотать ветку. Удалённый репозитории не зарегистрирован локально", "FwdCommitsToPush": "Невозможно перемотать ветку с коммитами для отправки", "ErrorOccurred": "Произошла ошибка! Пожалуйста, заявите о проблеме на", "NoRoom": "Недостаточно места", "YouAreHere": "ВЫ ЗДЕСЬ", "YouDied": "ТЫ УМЕР!", "RewordNotSupported": "Переформулировка коммитов при интерактивном перебазировании в настоящее время не поддерживается", "ChangingThisActionIsNotAllowed": "Изменение этого типа записи todo перебазирования не допускается", "CherryPickCopy": "Скопировать отобранные коммит (cherry-pick)", "PasteCommits": "Вставить отобранные коммиты (cherry-pick)", "CherryPick": "Выборочная отборка (Cherry-Pick)", "Donate": "Пожертвовать", "AskQuestion": "Задать вопрос", "PrevLine": "Выбрать предыдущую строку", "NextLine": "Выбрать следующую строку", "PrevHunk": "Выбрать предыдущую часть", "NextHunk": "Выбрать следующую часть", "PrevConflict": "Выбрать предыдущий конфликт", "NextConflict": "Выбрать следующий конфликт", "SelectPrevHunk": "Выбрать предыдущую часть", "SelectNextHunk": "Выбрать следующую часть", "ScrollDown": "Прокрутить вниз", "ScrollUp": "Прокрутить вверх", "ScrollUpMainWindow": "Прокрутить вверх главную панель", "ScrollDownMainWindow": "Прокрутить вниз главную панель", "AmendCommitTitle": "Править коммит (amend)", "AmendCommitPrompt": "Вы уверены, что хотите править этот коммит проиндексированными файлами?", "DropCommitTitle": "Удалить коммит", "DropCommitPrompt": "Вы уверены, что хотите удалить этот коммит?", "PullingStatus": "Получение и слияние изменении", "PushingStatus": "Отправка изменении", "FetchingStatus": "Получение изменении", "SquashingStatus": "Объединение коммитов", "FixingStatus": "Объединение коммитов, отбросив сообщение коммита", "DeletingStatus": "Удаление", "MovingStatus": "Перемещение", "RebasingStatus": "Перебазирование", "MergingStatus": "Слияние", "LowercaseRebasingStatus": "перебазировка", "LowercaseMergingStatus": "слияние", "AmendingStatus": "Правка коммита", "CherryPickingStatus": "Выборочная отборка (cherry-picking)", "UndoingStatus": "Отмена последней команды", "RedoingStatus": "Выполнение последней команды", "CheckingOutStatus": "Переключение", "CommittingStatus": "Сохранение изменении", "CommitFiles": "Сохранить изменения файлов", "SubCommitsDynamicTitle": "Коммиты (%s)", "CommitFilesDynamicTitle": "Различия файлов (%s)", "RemoteBranchesDynamicTitle": "Удалённые ветки (%s)", "ViewItemFiles": "Просмотреть файлы выбранного элемента", "CommitFilesTitle": "Сохранить Изменения Файлов", "CheckoutCommitFileTooltip": "Переключить файл", "CanOnlyDiscardFromLocalCommits": "Изменения можно отменить только из локальных коммитов.", "DiscardOldFileChangeTooltip": "Отменить изменения коммита в этом файле", "DiscardFileChangesTitle": "Отменить изменения файла", "DiscardFileChangesPrompt": "Вы уверены, что хотите удалить изменения в выбранных файлах из этого коммита?\n\nЭто действие запустит перебазирование и отменит изменения в этих файлах. Обратите внимание, что если последующие коммиты зависят от этих изменений, вам, возможно, придется разрешить конфликты.\nПримечание: это также сбросит все активные пользовательские патчи.", "CreateRepo": "Не в git репозитории. Создать новый git репозиторий? (y/n):", "BareRepo": "Вы пытались открыть Lazygit в пустом репозитории, но Lazygit ещё не поддерживает пустые репозитории. Открыть последний репозиторий? (y/n)", "InitialBranch": "Название ветки? (оставьте пустым для git по умолчанию):", "NoRecentRepositories": "Необходимо открыть lazygit в git репозитории. Нет валидных последних репозиториев. Выход.", "IncorrectNotARepository": "Неверное значение 'notARepository'. Это должно быть одним из 'prompt', 'create', 'skip', или 'quit'.", "AutoStashTitle": "Автосохранить изменения?", "AutoStashPrompt": "Чтобы перенести изменения, их нужно сохранить и вынуть. Сделать это автоматически? (enter/esc)", "StashPrefix": "Автосохранение изменений для", "Discard": "Просмотреть параметры «отмены изменении»", "Cancel": "Отменить", "DiscardAllChanges": "Отменить все изменения", "DiscardUnstagedChanges": "Отменить непроиндексированные изменения", "DiscardAllChangesToAllFiles": "Разбомбить рабочее дерево?", "DiscardAnyUnstagedChanges": "Отменить непроиндексированные изменения", "DiscardUntrackedFiles": "Удалить неотслеживаемые файлы", "DiscardStagedChanges": "Отменить проиндексированные изменения", "HardReset": "Жёсткий сброс", "ViewResetOptions": "Просмотреть параметры сброса", "CreateFixupCommit": "Создать fixup коммит", "CreateFixupCommitTooltip": "Создать fixup коммит для этого коммита", "SquashAboveCommitsTooltip": "Объединить все 'fixup!' коммиты выше в выбранный коммит (автосохранение)", "CommitChangesWithoutHook": "Закоммитить изменения без предварительного хука коммита", "ResetTo": "Сбросить на", "PressEnterToReturn": "Нажмите Enter, чтобы вернуться в lazygit", "ViewStashOptions": "Просмотреть параметры хранилища", "StashAllChanges": "Припрятать все изменения", "StashStagedChanges": "Припрятать проиндексированные изменения", "StashAllChangesKeepIndex": "Припрятать все изменения и сохранить индекс", "StashUnstagedChanges": "Припрятать непроиндексированные изменения", "StashIncludeUntrackedChanges": "Припрятать все изменения, включая неотслеживаемые файлы", "StashOptions": "Параметры хранилища", "NotARepository": "Ошибка: необходимо запустить внутри git репозитория", "Jump": "Перейти к панели", "ScrollLeftRight": "Прокрутить влево/вправо", "ScrollLeft": "Прокрутить влево", "ScrollRight": "Прокрутить вправо", "DiscardPatch": "Отменить патч", "DiscardPatchConfirm": "Вы можете собрать патч только из одной записи коммита/хранилища за раз. Отменить текущий патч?", "CantPatchWhileRebasingError": "Вы не можете создавать патчи или запускать команды патча, находясь в состоянии слияния или перемещения.", "ToggleAddToPatch": "Переключить файлы включённые в патч", "ToggleAllInPatch": "Переключить все файлы, включённые в патч", "UpdatingPatch": "Обновление патча", "ViewPatchOptions": "Просмотреть пользовательские параметры патча", "PatchOptionsTitle": "Параметры патча", "NoPatchError": "Патч ещё не создан. Чтобы начать сборку патча, используйте «пробел» в файле коммита или введите, чтобы добавить определённые строки.", "EnterCommitFile": "Введите файл, чтобы добавить выбранные строки в патч (или свернуть каталог переключения)", "ExitCustomPatchBuilder": "Выйти из сборщика пользовательских патчей", "EnterUpstream": "Введите upstream как ' '", "InvalidUpstream": "Недействительный upstream. Должен быть в формате ' '", "ReturnToRemotesList": "Вернуться к списку удалённых репозитории", "NewRemote": "Добавить новую удалённую ветку", "NewRemoteName": "Название новой удалённой ветки", "NewRemoteUrl": "Ссылка новой удалённой ветки", "EditRemoteName": "Введите новое название для удалённое ветки {{.remoteName}}:", "EditRemoteUrl": "Введите новую ссылку для удалённое ветки {{.remoteName}}:", "RemoveRemote": "Удалить удалённую ветку", "DeleteRemoteBranch": "Удалить Удалённую Ветку", "SetAsUpstreamTooltip": "Установить как upstream-ветку переключённую ветку", "SetUpstream": "Установить upstream-ветку из выбранной ветки", "UnsetUpstream": "Убрать upstream-ветку из выбранной ветки", "SetUpstreamTitle": "Установить upstream-ветку", "EditRemoteTooltip": "Редактировать удалённый репозитории", "TagCommit": "Пометить коммит тегом", "TagMenuTitle": "Создать тег", "TagNameTitle": "Название тега", "TagMessageTitle": "Сообщения тега", "LightweightTag": "Легковесный тег", "AnnotatedTag": "Аннотированный тег", "DeleteTagTitle": "Удалить тег", "PushTagTitle": "Удалённый репозитории для отправки тега '{{.tagName}}' в:", "PushTag": "Отправить тег", "NewTag": "Создать тег", "FetchRemoteTooltip": "Получение изменения из удалённого репозитория", "FetchingRemoteStatus": "Получение статуса удалённого репозитория", "CheckoutCommit": "Переключить коммит", "SureCheckoutThisCommit": "Вы уверены, что хотите переключить коммит?", "GitFlowOptions": "Показать параметры git-flow", "NotAGitFlowBranch": "Это не похоже на ветку git-flow", "NewBranchNamePrompt": "Введите новое название ветки", "IgnoreTracked": "Игнорировать отслеживаемый файл", "ExcludeTracked": "Исключить отслеживаемый файл", "IgnoreTrackedPrompt": "Вы уверены, что хотите игнорировать отслеживаемый файл?", "ExcludeTrackedPrompt": "Вы уверены, что хотите исключить отслеживаемый файл?", "ViewResetToUpstreamOptions": "Просмотреть параметры сброса upstream-ветки", "NextScreenMode": "Следующий режим экрана (нормальный/полуэкранный/полноэкранный)", "PrevScreenMode": "Предыдущий режим экрана", "StartSearch": "Найти", "Panel": "Панель", "Keybindings": "Связки клавиш", "KeybindingsLegend": "Связки клавиш", "RenameBranch": "Переименовать ветку", "NewGitFlowBranchPrompt": "Новое {{.branchType}} название:", "RenameBranchWarning": "Эта ветвь отслеживает удалённый репозитории. Это действие переименует только имя локальной ветки, а не имя удалённой ветки. Продолжать?", "OpenKeybindingsMenu": "Открыть меню", "ResetCherryPick": "Сбросить отобранную (скопированную | cherry-picked) выборку коммитов", "NextTab": "Следующая вкладка", "PrevTab": "Предыдущая вкладка", "CantUndoWhileRebasing": "Невозможно отменить во время перебазирования", "CantRedoWhileRebasing": "Невозможно повторить при перебазировании", "MustStashWarning": "Вытаскивание исправления в индекс требует сохранения и распаковки ваших изменений. Если что-то пойдёт не так, можно получить доступ к файлам из хранилища. Продолжить?", "MustStashTitle": "Необходимо припрятать", "ConfirmationTitle": "Панель Подтверждения", "PrevPage": "Предыдущая страница", "NextPage": "Следующая страница", "GotoTop": "Пролистать наверх", "GotoBottom": "Прокрутить вниз", "FilteringBy": "Фильтрация по", "ResetInParentheses": "(сбросить)", "OpenFilteringMenu": "Просмотреть параметры фильтрации по пути", "FilterBy": "Фильтровать по", "ExitFilterMode": "Прекратить фильтрацию по пути", "FilterPathOption": "Введите путь для фильтрации", "EnterFileName": "Введите путь:", "FilteringMenuTitle": "Фильтрация", "MustExitFilterModeTitle": "Команда недоступна", "MustExitFilterModePrompt": "Команда недоступна в режиме фильтрации. Выйти из режима фильтрации?", "Diff": "Разница", "EnterRefToDiff": "Введите ссылку для сравнения", "EnterRefName": "Введите ссылку:", "ExitDiffMode": "Выйти из режима сравнения", "DiffingMenuTitle": "Сравнение", "SwapDiff": "Обратное направление сравнении", "ViewDiffingOptions": "Открыть меню сравнении", "OpenCommandLogMenu": "Открыть меню журнала команд", "ShowingGitDiff": "Показывает вывод для:", "CommitDiff": "Разница коммита", "CopyCommitHashToClipboard": "Скопировать hash коммита в буфер обмена", "CommitHash": "hash коммита", "CommitURL": "URL коммита", "CopyCommitMessageToClipboard": "Скопировать сообщение коммита в буфер обмена", "CommitMessage": "Полное сообщение коммита", "CommitSubject": "Тема коммита", "CommitAuthor": "Автор коммита", "CopyCommitAttributeToClipboard": "Скопировать атрибут коммита", "CopyBranchNameToClipboard": "Скопировать название ветки в буфер обмена", "CopyPathToClipboard": "Скопировать название файла в буфер обмена", "CommitPrefixPatternError": "Ошибка в шаблоне commitPrefix", "CopySelectedTextToClipboard": "Скопировать выделенный текст в буфер обмена", "NoFilesStagedTitle": "Нет проиндексированных файлов", "NoFilesStagedPrompt": "Нет проиндексированых файлов. Закоммитить все файлы?", "BranchNotFoundTitle": "Ветка не найдена", "BranchNotFoundPrompt": "Ветка не найден. Создайте новую ветку с названием", "BranchUnknown": "Ветка неизвестна", "DiscardChangeTitle": "Отменить изменение", "DiscardChangePrompt": "Вы уверены, что хотите отменить это изменение (git reset)? Это необратимо.\nЧтобы отключить этот диалог, установите для конфигурационного ключа 'gui.skipDiscardChangeWarning' значение true.", "CreateNewBranchFromCommit": "Создать новую ветку с этого коммита", "BuildingPatch": "Сборка патча", "ViewCommits": "Просмотреть коммиты", "RunningCustomCommandStatus": "Запуск пользовательской команды", "SubmoduleStashAndReset": "Спрятать непроиндексированные изменения подмодуля и обновить", "AndResetSubmodules": "И сбросить подмодули", "EnterSubmoduleTooltip": "Ввести подмодуль", "CopySubmoduleNameToClipboard": "Скопировать название подмодуля в буфер обмена", "RemoveSubmodule": "Удалить подмодуль", "RemoveSubmodulePrompt": "Вы уверены, что хотите удалить подмодуль '%s' и соответствующий ему каталог? Это необратимо.", "ResettingSubmoduleStatus": "Сброс подмодуля", "NewSubmoduleName": "Названия нового подмодуля:", "NewSubmoduleUrl": "URL нового подмодуля:", "NewSubmodulePath": "Путь нового подмодуля:", "NewSubmodule": "Добавить новый подмодуль", "AddingSubmoduleStatus": "Добавление подмодуля", "UpdateSubmoduleUrl": "Обновить URL подмодуля '%s'", "UpdatingSubmoduleUrlStatus": "Обновление URL", "EditSubmoduleUrl": "Обновить URL подмодуля", "InitializingSubmoduleStatus": "Инициализация подмодуля", "InitSubmoduleTooltip": "Инициализировать подмодуль", "SubmoduleUpdateTooltip": "Обновить подмодуль", "UpdatingSubmoduleStatus": "Обновление подмодуля", "BulkInitSubmodules": "Массовая инициализация подмодулей", "BulkUpdateSubmodules": "Массовое обновление подмодулей", "BulkDeinitSubmodules": "Массовая деинициализация подмодулей", "ViewBulkSubmoduleOptions": "Просмотреть параметры массового подмодуля", "BulkSubmoduleOptions": "Параметры массового подмодуля", "RunningCommand": "Выполнение команды", "SubCommitsTitle": "Подкоммиты", "SubmodulesTitle": "Подмодули", "NavigationTitle": "Навигация по панели списка", "SuggestionsCheatsheetTitle": "Подсказки", "SuggestionsTitle": "Подсказки (нажмите %s, чтобы сфокусироваться)", "ExtrasTitle": "Журнал команд", "PushingTagStatus": "Отправка тега", "PullRequestURLCopiedToClipboard": "URL запроса на принятие изменений скопирован в буфер обмена", "CommitDiffCopiedToClipboard": "Сравнения коммита скопированы в буфер обмена", "CommitURLCopiedToClipboard": "URL коммита скопирован в буфер обмена", "CommitMessageCopiedToClipboard": "Сообщение коммита скопировано в буфер обмена", "CommitSubjectCopiedToClipboard": "Тема коммита скопирована в буфер обмена", "CommitAuthorCopiedToClipboard": "Автор коммита скопирован в буфер обмена", "PatchCopiedToClipboard": "Патч скопирован в буфер обмена", "CopiedToClipboard": "Скопировано в буфер обмена", "ErrCannotEditDirectory": "Невозможно редактировать каталог: вы можете редактировать только отдельные файлы", "ErrStageDirWithInlineMergeConflicts": "Невозможно подготовить/удалить каталог, содержащий файлы со встроенными конфликтами слияния. Сначала устраните конфликты слияния", "ErrRepositoryMovedOrDeleted": "Не могу найти репозиторий. Возможно, он был перемещён или удалён ¯\\_(ツ)_/¯", "CommandLog": "Журнал команд", "ToggleShowCommandLog": "Показать/скрыть журнал команд", "FocusCommandLog": "Сфокусировать журнал команд", "CommandLogHeader": "Вы можете скрыть/сфокусировать эту панель, нажав '%s'\n", "RandomTip": "Случайный совет", "SelectParentCommitForMerge": "Выберите родительский коммит для слияния", "ToggleWhitespaceInDiffView": "Переключить отображение изменении пробелов в просмотрщике сравнении", "IgnoreWhitespaceDiffViewSubTitle": "(игнорирование пробелов)", "IgnoreWhitespaceNotSupportedHere": "Игнорирование пробелов не поддерживается в этом представлении", "IncreaseContextInDiffView": "Увеличить размер контекста, отображаемого вокруг изменений в просмотрщике сравнении", "DecreaseContextInDiffView": "Уменьшите размер контекста, отображаемого вокруг изменений в просмотрщике сравнении", "CreatePullRequestOptions": "Создать параметры запроса принятие изменений", "DefaultBranch": "Ветка по-умолчанию", "SelectBranch": "Выбрать ветку", "CreatePullRequest": "Создать запрос на принятие изменений", "SelectConfigFile": "Выбрать файл конфигурации", "NoConfigFileFoundErr": "Файл конфигурации не найден", "LoadingFileSuggestions": "Загрузка подсказок по файлам", "LoadingCommits": "Загрузка коммитов", "MustSpecifyOriginError": "Необходимо указать удалённый репозитории, если указываете ветку", "GitOutput": "Вывод git:", "GitCommandFailed": "Ошибка команды Git. Подробности смотрите в журнале команд (открыть с помощью %s)", "AbortTitle": "Прервать %s", "AbortPrompt": "Вы уверены, что хотите прервать текущий %s?", "OpenLogMenu": "Открыть меню журнала", "LogMenuTitle": "Параметры журнала коммитов", "ToggleShowGitGraphAll": "Переключить отображение всего git графа (передать флаг --all в git log )", "ShowGitGraph": "Показать git граф", "SortOrder": "Порядок сортировки", "SortAlphabetical": "По алфавиту", "SortByDate": "По дате", "SortCommits": "Упорядочить коммиты", "CantChangeContextSizeError": "Невозможно изменить контекст в режиме создания патча, потому что мы были слишком ленивы, чтобы поддерживать его при выпуске функции. Если вы действительно этого хотите, пожалуйста, дайте нам знать!", "OpenCommitInBrowser": "Открыть коммит в браузере", "ViewBisectOptions": "Просмотреть параметры бинарного поиска", "ConfirmRevertCommit": "Вы уверены, что хотите отменить {{.selectedCommit}}?", "RewordInEditorTitle": "Перефразировать в редакторе", "RewordInEditorPrompt": "Вы уверены, что хотите перефразировать этот коммит вашем редакторе?", "HardResetAutostashPrompt": "Вы уверены, что хотите сделать жёсткий сброс на '%s'? При необходимости будет выполнен автосохранение в хранилище.", "NukeDescription": "Если вы хотите, чтобы все изменения в рабочем дереве исчезли, это способ сделать это. Если есть какие-либо изменения подмодуля, эти изменения будут припрятаны в подмодуле(-ях).", "DiscardStagedChangesDescription": "Это создаст новую запись в хранилище, содержащую только проиндексированные файлы, а затем удалит её, так что в рабочем дереве останутся только непроиндексированные изменения.", "EmptyOutput": "<Пустой вывод>", "Patch": "Патч", "CustomPatch": "Пользовательский патч", "CommitsCopied": "коммиты скопированы", "CommitCopied": "коммит скопирован", "ResetPatch": "Сбросить патч", "ApplyPatch": "Применить патч", "ApplyPatchInReverse": "Применить патч в обратном порядке", "RemovePatchFromOriginalCommit": "Удалить патч из исходного коммита (%s)", "MovePatchOutIntoIndex": "Переместить патч в индекс", "MovePatchIntoNewCommit": "Переместить патч в новый коммит", "MovePatchToSelectedCommit": "Переместить патч в выбранный коммит (%s)", "CopyPatchToClipboard": "Скопировать патч в буфер обмена", "NoMatchesFor": "Нет совпадений для '%s' %s", "MatchesFor": "совпадений для '%s' (%d из %d) %s", "SearchKeybindings": "%s: Следующее совпадение, %s: Предыдущее совпадение, %s: Выйти из режима поиска", "SearchPrefix": "Поиск: ", "ExitSearchMode": "%s: Выйти из режима поиска", "ToggleRangeSelect": "Переключить выборку перетаскивания", "Actions": { "CheckoutCommit": "Переключить коммит", "CheckoutTag": "Переключить тег", "CheckoutBranch": "Переключить ветку", "ForceCheckoutBranch": "Принудительное переключение ветки", "Merge": "Слить", "RebaseBranch": "Перебазировать ветку", "RenameBranch": "Переименовать ветку", "CreateBranch": "Создать ветку", "FastForwardBranch": "Ветка перемотки вперёд", "CherryPick": "(Cherry-pick) Вставить коммиты", "CheckoutFile": "Переключить файл", "DiscardOldFileChange": "Отменить старое изменение файла", "SquashCommitDown": "Объединить несколько коммитов в один нижний", "FixupCommit": "Объединить несколько коммитов в один, отбросив сообщение коммита", "RewordCommit": "Перефразировать коммит", "DropCommit": "Сбросить коммит", "EditCommit": "Изменить коммит", "AmendCommit": "Править коммит (amend)", "ResetCommitAuthor": "Сброс автора коммита", "SetCommitAuthor": "Установить автора коммита", "RevertCommit": "Отменить коммит", "CreateFixupCommit": "Создать fixup коммит", "SquashAllAboveFixupCommits": "Объединить все выше fixup коммиты", "MoveCommitUp": "Переместить коммит вверх", "MoveCommitDown": "Переместить коммит вниз", "CopyCommitMessageToClipboard": "Скопировать сообщение коммита в буфер обмена", "CopyCommitSubjectToClipboard": "Скопировать тему коммита в буфер обмена", "CopyCommitDiffToClipboard": "Скопировать сравнения коммита в буфер обмена", "CopyCommitHashToClipboard": "Скопировать hash коммита в буфер обмена", "CopyCommitURLToClipboard": "Скопировать URL коммита в буфер обмена", "CopyCommitAuthorToClipboard": "Скопировать автора коммита в буфер обмена", "CopyCommitAttributeToClipboard": "Скопировать в буфер обмена", "CopyPatchToClipboard": "Скопировать патч в буфер обмена", "CustomCommand": "Пользовательская команда", "DiscardAllChangesInDirectory": "Отменить все изменения в каталоге", "DiscardUnstagedChangesInDirectory": "Отменить непроиндексированные изменения в каталоге", "DiscardAllChangesInFile": "Отменить все изменения в файле", "DiscardAllUnstagedChangesInFile": "Отменить все непроиндексированные изменения в файле", "StageFile": "Проиндексировать файл", "StageResolvedFiles": "Проиндексированные файлы, конфликты слияния которых были устранены", "UnstageFile": "Непроиндексированные файл", "UnstageAllFiles": "Удалить все файлы из индекса", "StageAllFiles": "Проиндексировать все файлы", "IgnoreExcludeFile": "Игнорировать или исключить файл", "IgnoreFileErr": "Невозможно игнорировать .gitignore", "ExcludeFile": "Исключить файл", "ExcludeGitIgnoreErr": "Невозможно исключить .gitignore", "Commit": "Коммит", "EditFile": "Редактировать файл", "Push": "Отправить изменения", "Pull": "Получить и слить изменения", "OpenFile": "Открыть файл", "StashAllChanges": "Припрятать все изменения", "StashAllChangesKeepIndex": "Припрятать все изменения и сохранить индекс", "StashStagedChanges": "Припрятать проиндексированные изменения", "StashUnstagedChanges": "Припрятать непроиндексированные изменения", "StashIncludeUntrackedChanges": "Припрятать все изменения, включая неотслеживаемые файлы", "GitFlowFinish": "Завершение Git-потока", "GitFlowStart": "Запуск Git-потока", "CopyToClipboard": "Скопировать в буфер обмена", "CopySelectedTextToClipboard": "Скопировать выделенный текст в буфер обмена", "RemovePatchFromCommit": "Удалить патч из коммита", "MovePatchToSelectedCommit": "Переместить патч в выбранный коммит", "MovePatchIntoIndex": "Переместите патч в индекс", "MovePatchIntoNewCommit": "Переместить патч в новый коммит", "DeleteRemoteBranch": "Удалить удалённую ветку", "SetBranchUpstream": "Установить ветку как upstream", "AddRemote": "Добавить удалённую ветку", "RemoveRemote": "Удалить удалённую ветку", "UpdateRemote": "Обновить удалённую ветку", "ApplyPatch": "Применить патч", "Stash": "Хранилище", "RenameStash": "Переименовать хранилище", "RemoveSubmodule": "Удалить подмодуль", "ResetSubmodule": "Сброс подмодуля", "AddSubmodule": "Добавить подмодуль", "UpdateSubmoduleUrl": "Обновить URL подмодуля", "InitialiseSubmodule": "Инициализация подмодуля", "BulkInitialiseSubmodules": "Массовая инициализация подмодулей", "BulkUpdateSubmodules": "Массовое обновление подмодулей", "BulkDeinitialiseSubmodules": "Массовая деинициализация подмодулей", "UpdateSubmodule": "Обновить подмодуль", "CreateLightweightTag": "Создать легковесный тег", "CreateAnnotatedTag": "Создать аннотированный тег", "PushTag": "Отправить тег", "NukeWorkingTree": "Уничтожить рабочее дерево", "DiscardUnstagedFileChanges": "Отменить непроиндексированные изменения файла", "RemoveUntrackedFiles": "Удалить неотслеживаемые файлы", "RemoveStagedFiles": "Удалить проиндексированные файлы", "SoftReset": "Мягкий сброс", "MixedReset": "Смешанный сброс", "HardReset": "Жёсткий сброс", "Undo": "Отменить", "Redo": "Повторить", "CopyPullRequestURL": "Скопировать запрос на принятие изменений URL", "OpenMergeTool": "Открыть инструмент слияния", "OpenCommitInBrowser": "Открыть коммит в браузере", "OpenPullRequest": "Открыть запрос на принятие изменений в браузера", "StartBisect": "Начать бинарный поиск", "ResetBisect": "Сбросить бинарный поиск", "BisectSkip": "Пропустить бинарный поиск", "BisectMark": "Отметить бинарный поиск" }, "Bisect": { "MarkStart": "Отметить %s как %s (начать бинарный поиск)", "ResetTitle": "Сбросить 'git bisect'", "ResetPrompt": "Вы уверены, что хотите сбросить 'git bisect'?", "ResetOption": "Сбросить бинарный поиск", "BisectMenuTitle": "Бинарный поиск", "Mark": "Отметить %s как %s", "SkipCurrent": "Пропустить %s", "CompleteTitle": "Бинарный поиск завершён", "CompletePrompt": "Бинарный поиск завершён! Изменения внесённые следующим коммитом:\n\n%s\n\nСбросить 'git bisect' сейчас?", "CompletePromptIndeterminate": "Бинарный поиск завершён! Некоторые коммиты были пропущены, поэтому любое из следующих коммитов могло внести изменения::\n\n%s\n\nСбросить 'git bisect' сейчас?", "Bisecting": "Бинарный поиск" }, "Log": {}, "BreakingChangesByVersion": {} } lazygit-0.50.0+ds1/pkg/i18n/translations/zh-CN.json000066400000000000000000001674671500612110400216500ustar00rootroot00000000000000{ "NotEnoughSpace": "没有足够的空间来渲染面板", "DiffTitle": "差异", "FilesTitle": "文件", "BranchesTitle": "分支", "CommitsTitle": "提交", "StashTitle": "贮藏", "SnakeTitle": "贪吃蛇", "EasterEgg": "彩蛋", "UnstagedChanges": "未暂存变更", "StagedChanges": "已暂存变更", "MainTitle": "主要", "StagingTitle": "正在暂存", "MergingTitle": "正在合并", "SquashMergeUncommittedTitle": "压缩合并并保持未提交状态", "SquashMergeCommittedTitle": "压缩合并,然后提交", "SquashMergeUncommitted": "将分支 ‘{{.selectedBranch}}’ 压缩合并到工作树中", "SquashMergeCommitted": "将分支 '{{.selectedBranch}}' 压缩合并为单个提交,到 '{{.checkedOutBranch}}' 分支中。", "RegularMergeTooltip": "将分支 '{{.selectedBranch}}' 合并到 '{{.checkedOutBranch}}'", "NormalTitle": "正常", "LogTitle": "日志", "CommitSummary": "提交信息", "CredentialsUsername": "用户名", "CredentialsPassword": "密码", "CredentialsPassphrase": "输入 SSH 密钥的密码", "CredentialsPIN": "为 SSH key 输入PIN", "CredentialsToken": "输入 SSH 密钥令牌", "PassUnameWrong": "密码 和/或 用户名错误", "Commit": "提交变更", "CommitTooltip": "提交暂存文件", "AmendLastCommit": "修补最后一次提交", "AmendLastCommitTitle": "修补最后一次提交", "SureToAmend": "您确定要修补上一次提交吗?之后您可以从提交面板更改提交消息", "NoCommitToAmend": "没有需要提交的修补", "CommitChangesWithEditor": "使用 Git 编辑器提交变更", "FindBaseCommitForFixup": "找到用于修复的基准提交", "FindBaseCommitForFixupTooltip": "找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: ", "NoBaseCommitsFound": "没有找到接触提交", "MultipleBaseCommitsFoundStaged": "找到了多个基础提交 (尝试一次暂存更少的变更)", "MultipleBaseCommitsFoundUnstaged": "找到了多个基础提交 (尝试暂存部分变更)", "BaseCommitIsAlreadyOnMainBranch": "此变更内容所在的提交已经存在于主分支上了", "BaseCommitIsNotInCurrentView": "基础提交不在当前视图中", "HunksWithOnlyAddedLinesWarning": "差异中仅包含添加行,小心检查这些是否属于已找到的主提交。\n\n是否继续?", "StatusTitle": "状态", "GlobalTitle": "全局键绑定", "Menu": "菜单", "Execute": "执行", "Stage": "切换暂存状态", "StageTooltip": "为选定的文件切换暂存状态", "ToggleStagedAll": "切换所有文件的暂存状态", "ToggleStagedAllTooltip": "切换工作区中所有文件的已暂存/未暂存状态", "ToggleTreeView": "切换文件树视图", "ToggleTreeViewTooltip": "在平铺部署与树布局之间切换文件视图。平铺布局在一个列表中展示所有文件路径,树布局则根据目录分组展示。", "OpenDiffTool": "使用外部差异比较工具(git difftool)", "OpenMergeTool": "打开外部合并工具(git mergetool)", "OpenMergeToolTooltip": "执行 `git mergetool`.", "Refresh": "刷新", "RefreshTooltip": "刷新git状态(即在后台上运行`git status`,`git branch`等命令以更新面板内容) 不会运行`git fetch`", "Push": "推送", "Pull": "拉取", "PushTooltip": "推送当前分支到它的上游。如果上游未配置,你可以在弹窗中配置上游分支。", "PullTooltip": "从当前分支的远程分支获取改动。如果上游未配置,你可以在弹窗中配置上游分支。", "Scroll": "滚动", "FileFilter": "通过状态过滤文件", "CopyToClipboardMenu": "复制到剪贴板", "CopyFileName": "文件名", "CopyFileDiffTooltip": "如果存在已暂存更改,该命令将仅作用于它们。否则将作用于所有未暂存更改。", "CopySelectedDiff": "比较选中的文件", "CopyAllFilesDiff": "对比全部文件", "NoContentToCopyError": "无可复制内容", "FileNameCopiedToast": "文件名已复制至剪贴板", "FilePathCopiedToast": "文件路径已复制至剪贴板", "FileDiffCopiedToast": "文件差异已复制至剪贴板", "AllFilesDiffCopiedToast": "全部文件差异已复制至剪贴板", "FilterStagedFiles": "仅显示已暂存文件", "FilterUnstagedFiles": "仅显示未暂存文件", "FilterTrackedFiles": "仅显示已跟踪的文件", "FilterUntrackedFiles": "仅显示未跟踪的文件", "NoFilter": "无过滤", "FilterLabelStagedFiles": "(仅暂存)", "FilterLabelUnstagedFiles": "(仅未暂存)", "FilterLabelTrackedFiles": "(仅跟踪)", "FilterLabelUntrackedFiles": "(仅未跟踪)", "FilterLabelConflictingFiles": "(仅冲突)", "MergeConflictsTitle": "合并冲突", "Checkout": "检出", "CheckoutTooltip": "检出选中的项目", "CantCheckoutBranchWhilePulling": "当前分支在拉取远端时,无法检出到其他分支。", "TagCheckoutTooltip": "检出选择的标签作为分离的HEAD", "RemoteBranchCheckoutTooltip": "基于当前选中的远程分支检出一个新的本地分支,或者将远程分支作分离的HEAD。", "CantPullOrPushSameBranchTwice": "在推送或拉取分支的过程中,你不能再次推送或拉取同一个分支", "NoChangedFiles": "没有变更的文件", "SoftReset": "软重置", "AlreadyCheckedOutBranch": "您已经检出至此分支", "SureForceCheckout": "您确定要强制检出吗?您将丢失所有本地变更", "ForceCheckoutBranch": "强制检出分支", "BranchName": "分支名称", "NewBranchNameBranchOff": "新分支名称(基于 {{.branchName}})", "CantDeleteCheckOutBranch": "您不能删除已检出的分支!", "DeleteBranchTitle": "删除分支'{{.selectedBranchName}}'?", "DeleteBranchesTitle": "删除选定的分支?", "DeleteLocalBranch": "删除本地分支", "DeleteLocalBranches": "删除本地分支", "DeleteRemoteBranchOption": "删除远程分支", "DeleteRemoteBranchPrompt": "你确定要从'{{.upstream}}'中删除远程分支'{{.selectedBranchName}}'?", "ForceDeleteBranchTitle": "强制删除分支", "ForceDeleteBranchMessage": "{{.selectedBranchName}} 还没有被完全合并。您确定要删除它吗?", "RebaseBranch": "将已检出的分支变基到该分支", "RebaseBranchTooltip": "将检出的分支变基到所选的分支上。", "CantRebaseOntoSelf": "您不能将分支变基到其自身", "CantMergeBranchIntoItself": "您不能将分支合并到其自身", "ForceCheckout": "强制检出", "ForceCheckoutTooltip": "强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。", "CheckoutByName": "按名称检出", "CheckoutByNameTooltip": "按名称检出。在输入框中,您可以输入'-' 来切换到最后一个分支。", "RemoteBranchCheckoutTitle": "检出 {{.branchName}}", "RemoteBranchCheckoutPrompt": "您希望已什么方式检出到该分支?", "CheckoutTypeNewBranch": "新建本地分支", "CheckoutTypeNewBranchTooltip": "检出远程分支到本地,并跟踪它。", "CheckoutTypeDetachedHead": "分离HEAD", "CheckoutTypeDetachedHeadTooltip": "将远程分支检出作为分离HEAD,如果您只想用来测试而不是正式使用,这会很有用。您之后仍可以根据它创建本地分支。", "NewBranch": "新分支", "NewBranchFromStashTooltip": "从选定的贮藏项创建一个新分支。这是通过 git 检查创建贮藏项的提交,从该提交创建一个新分支,然后将贮藏项作为附加提交应用到新分支来实现的。", "NoBranchesThisRepo": "此仓库中没有分支", "CommitWithoutMessageErr": "您必须编写提交消息才能进行提交", "Close": "关闭", "CloseCancel": "关闭", "Confirm": "确认", "Quit": "退出", "SquashTooltip": "将已选提交压缩到该提交之下。这些选定的提交的消息会附加到该提交的消息之下。", "CannotSquashOrFixupFirstCommit": "下面没有可以压缩的提交", "Fixup": "修正 (fixup)", "FixupTooltip": "将选定的提交合并到其下面的提交中。与压缩类似,但所选提交的消息将被丢弃。", "SureFixupThisCommit": "您确定要“修正”此提交吗?它将合并到下面的提交中", "SureSquashThisCommit": "您确定要将这个提交压缩到下面的提交中吗?", "Squash": "压缩(Squash)", "SquashMerge": "压缩合并", "PickCommitTooltip": "标记选中的提交为 picked(变基过程中)。这意味该提交将在后续的变基中保留。", "Pick": "拣选(Pick)", "CantPickDisabledReason": "在变基期间,无法选择提交", "Edit": "编辑", "RevertCommit": "还原提交", "Revert": "撤销(Revert)", "RevertCommitTooltip": "为所选提交创建还原提交,这会反向应用所选提交的更改。", "Reword": "改写提交", "CommitRewordTooltip": "重写所选提交的消息。", "DropCommit": "删除提交", "DropCommitTooltip": "删除选中的提交。这将通过变基从分支中删除该提交,如果该提交修改的内容依赖于后续的提交,则需要解决合并冲突。", "MoveDownCommit": "下移提交", "MoveUpCommit": "上移提交", "CannotMoveAnyFurther": "无法进一步移动", "EditCommit": "编辑(开始交互式变基)", "EditCommitTooltip": "编辑提交", "AmendCommitTooltip": "用已暂存的变更来修补提交", "Amend": "修补(Amend)", "ResetAuthor": "重置作者", "ResetAuthorTooltip": "将提交作者重置为当前配置的用户。这也将更新作者的时间戳", "SetAuthor": "设置作者", "SetAuthorTooltip": "基于提示设置作者", "AddCoAuthor": "添加共同作者", "AmendCommitAttribute": "修补提交属性", "AmendCommitAttributeTooltip": "设置或重置提交的作者,或添加其他作者。", "SetAuthorPromptTitle": "设置作者(格式为 'Name ')", "AddCoAuthorPromptTitle": "添加共同作者(格式为 'Name ')", "AddCoAuthorTooltip": "添加共同作者 使用GitHub/GitLab元数据共同作者(Co-authored-by)", "SureResetCommitAuthor": "将更新提交的作者名称,同时也将更新提交时间,是否继续?", "RewordCommitEditor": "使用编辑器重命名提交", "NoCommitsThisBranch": "该分支没有提交", "UpdateRefHere": "更新分支到 '{{.ref}}'", "ExecCommandHere": "在这里执行以下命令:", "Error": "错误", "Undo": "撤销", "UndoReflog": "撤销", "RedoReflog": "重做", "UndoTooltip": "Reflog将用于确定运行哪个git命令来撤消最后一个git命令。这并不包括对工作树的更改,只考虑提交。", "RedoTooltip": "Reflog将用于确定运行哪个git命令来重做上一个git命令。这并不包括对工作树的更改,只考虑提交。", "UndoMergeResolveTooltip": "撤消上次合并冲突解决", "DiscardAllTooltip": "丢弃'{{.path}}'中已暂存和未暂存的变更", "DiscardUnstagedTooltip": "丢弃'{{.path}}'中未暂存的变更", "DiscardUnstagedDisabled": "选中的项目既没有已暂存的变更也没有未暂存的变更", "Pop": "应用并删除", "StashPopTooltip": "将存储项应用到工作目录并删除存储项。", "Drop": "删除", "StashDropTooltip": "从贮藏列表中删除该贮藏项", "Apply": "应用", "StashApplyTooltip": "将贮藏项应用到您的工作目录。", "NoStashEntries": "没有贮藏条目", "StashDrop": "删除贮藏", "StashPop": "应用并删除贮藏", "SurePopStashEntry": "您确定要应用并删除此贮藏条目吗?", "StashApply": "应用贮藏", "SureApplyStashEntry": "您确定要应用此贮藏条目?", "NoTrackedStagedFilesStash": "没有可以贮藏的已跟踪/暂存文件", "NoFilesToStash": "没有需要贮藏的文件", "StashChanges": "贮藏变更", "RenameStash": "重命名贮藏", "RenameStashPrompt": "重命名贮藏: {{.stashName}}", "OpenConfig": "打开配置文件", "EditConfig": "编辑配置文件", "ForcePush": "强制推送", "ForcePushPrompt": "您的分支已与远程分支不同。按‘esc’取消,或‘enter’强制推送.", "ForcePushDisabled": "您的分支已与远程分支不同, 并且您已经禁用了强行推送", "UpdatesRejected": "更新被拒绝。在下次推送前,请先抓取并检查远程分支。", "UpdatesRejectedAndForcePushDisabled": "更新被拒绝,您已禁用强制推送", "CheckForUpdate": "检查更新", "CheckingForUpdates": "正在检查更新…", "UpdateAvailableTitle": "有可用更新!", "UpdateAvailable": "下载并安装版本 {{.newVersion}}?", "UpdateInProgressWaitingStatus": "更新中", "UpdateCompletedTitle": "更新完成!", "UpdateCompleted": "已成功安装更新,重新启动 lazygit 使其生效。", "FailedToRetrieveLatestVersionErr": "检索版本信息失败", "OnLatestVersionErr": "已是最新版本", "MajorVersionErr": "新版本({{.newVersion}})与当前版本({{.currentVersion}})相比,具有非向后兼容的更改", "CouldNotFindBinaryErr": "在 {{.url}} 处找不到任何二进制文件", "UpdateFailedErr": "更新失败: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "当前正在更新中...", "ConfirmQuitDuringUpdate": "当前正在更新中,你确定要退出吗?", "MergeToolTitle": "合并工具", "MergeToolPrompt": "确定要打开 `git mergetool` 吗?", "IntroPopupMessage": "\n感谢使用 lazygit!你真的太棒了。下面几点你可能会感兴趣:\n\n 1) 观看此视频,快速了解 lazygit 的功能:\n https://youtu.be/CPLdltN7wgE\n\n 2) 记得看看最新发行说明:\n https://github.com/jesseduffield/lazygit/releases\n\n 3) 使用 git 说明你是一位程序员!你可以和我们一起让 lazygit 变得更好。\n 考虑为本项目做些贡献吧:\n https://github.com/jesseduffield/lazygit\n 你也可以直接赞助,并告诉我哪里需要改进,点右下角的捐赠按钮就好了。\n 哪怕只是给仓库点个星星也很棒!\n", "DeprecatedEditConfigWarning": "englishDeprecatedEditConfigWarning", "GitconfigParseErr": "由于存在未加引号的'\\'字符,因此 Gogit 无法解析您的 gitconfig 文件。删除它们应该可以解决问题。", "EditFile": "编辑文件", "EditFileTooltip": "使用外部编辑器打开文件", "OpenFile": "打开文件", "OpenFileTooltip": "使用默认程序打开该文件", "OpenInEditor": "在编辑器中编写", "IgnoreFile": "添加到 .gitignore", "ExcludeFile": "添加到 .git/info/exclude", "RefreshFiles": "刷新文件", "Merge": "合并到当前检出的分支", "RegularMerge": "常规合并", "MergeBranchTooltip": "Merge selected branch into currently checked out branch.", "ConfirmQuit": "您确定要退出吗?", "SwitchRepo": "切换到最近的仓库", "UnsupportedGitService": "不支持的 git 服务", "CopyPullRequestURL": "将拉取请求 URL 复制到剪贴板", "NoBranchOnRemote": "该分支在远程上不存在. 您需要先将其推送到远程.", "Fetch": "抓取", "FetchTooltip": "从远程获取变更", "CollapseAll": "折叠全部文件", "CollapseAllTooltip": "折叠文件树中的全部目录", "ExpandAll": "展开全部文件", "ExpandAllTooltip": "展开文件树中的全部目录", "FileEnter": "暂存单个 块/行 用于文件, 或 折叠/展开 目录", "FileEnterTooltip": "如果选中的是一个文件,则会进入到暂存视图,以便可以暂存单个代码块/行。如果选中的是一个目录,则会折叠/展开这个目录", "FileStagingRequirements": "只能暂存跟踪文件的单独行", "StageSelectionTooltip": "切换行暂存状态", "DiscardSelection": "取消变更(git reset)", "DiscardSelectionTooltip": "当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更", "ToggleSelectHunk": "切换选择代码块", "ToggleSelectHunkTooltip": "切换代码块选择模式", "ToggleSelectionForPatch": "添加/移除 行到补丁", "EditHunk": "编辑代码块", "EditHunkTooltip": "在外部编辑器中编辑选中的代码块", "ToggleStagingView": "切换到其他面板", "ToggleStagingViewTooltip": "切换到其他视图(已暂存/未暂存的变更)", "ReturnToFilesPanel": "返回文件面板", "FastForward": "从上游快进此分支", "FastForwardTooltip": "将当前分支直接移动到远程追踪分支的最新提交", "FastForwarding": "抓取并快进", "FoundConflictsTitle": "自动合并失败", "ViewConflictsMenuItem": "查看冲突", "AbortMenuItem": "中止 %s", "PickHunk": "选中区块", "PickAllHunks": "选中所有区块", "ViewMergeRebaseOptions": "查看 合并/变基 选项", "ViewMergeRebaseOptionsTooltip": "查看当前合并或变基的中止、继续、跳过选项", "ViewMergeOptions": "查看 合并(merge) 选项", "ViewRebaseOptions": "View rebase opGlobalTitletions", "NotMergingOrRebasing": "您目前既不进行变基也不进行合并", "AlreadyRebasing": "在变基时无法执行此操作", "RecentRepos": "最近的仓库", "MergeOptionsTitle": "合并选项", "RebaseOptionsTitle": "变基选项", "CommitSummaryTitle": "提交信息", "CommitDescriptionTitle": "提交信息说明", "CommitDescriptionSubTitle": "按 {{.togglePanelKeyBinding}} 键切换焦点, {{.commitMenuKeybinding}} 打开菜单", "CommitDescriptionFooter": "点击 {{.confirmInEditorKeybinding}} 提交", "LocalBranchesTitle": "本地分支", "SearchTitle": "搜索", "TagsTitle": "标签", "MenuTitle": "菜单", "CommitMenuTitle": "提交 菜单", "RemotesTitle": "远程", "RemoteBranchesTitle": "远程分支", "PatchBuildingTitle": "构建补丁中", "InformationTitle": "信息", "SecondaryTitle": "次要", "ReflogCommitsTitle": "Reflog", "ConflictsResolved": "已解决所有冲突。是否继续?", "Continue": "继续", "RebasingTitle": "变基 '{{.checkedOutBranch}}'", "RebasingFromBaseCommitTitle": "从标记的几点变基'{{.checkedOutBranch}}'", "SimpleRebase": "简单变基到 '{{.ref}}'", "InteractiveRebase": "交互式变基到 '{{.ref}}'", "RebaseOntoBaseBranch": "变基到主分支 ({{.baseBranch}})", "InteractiveRebaseTooltip": "由于交互式变基被中断,所以你可以在继续变基之前更新TODO提交。", "RebaseOntoBaseBranchTooltip": "将已检出的分支变基到主分支上(例如最近的主分支)。", "MustSelectTodoCommits": "在变基过程中, 该操作仅在选中TODO提交时有效。", "FwdNoUpstream": "此分支没有上游,无法快进", "FwdNoLocalUpstream": "此分支的远程未在本地注册,无法快进", "FwdCommitsToPush": "此分支带有尚未推送的提交,无法快进", "PullRequestNoUpstream": "没有设置上游的分支无法执行拉取请求", "ErrorOccurred": "发生错误!请在以下位置创建 issue", "NoRoom": "空间不足", "YouAreHere": "您在这里", "YouDied": "你死了!", "RewordNotSupported": "当前不支持交互式重新基准化时的重新措词提交", "ChangingThisActionIsNotAllowed": "不允许更改这类变基待办项目", "CherryPickCopy": "复制提交(拣选)", "CherryPickCopyTooltip": "标记提交为已复制。然后,在本地提交视图中,你可以按 `{{.paste}}` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `{{.escape}}` 来取消选择。", "CherryPickCopyRangeTooltip": "将上次已复制的提交到当前选中的提交都标记为已复制", "PasteCommits": "粘贴提交(拣选)", "CherryPick": "拣选(Cherry-Pick)", "CannotCherryPickNonCommit": "无法拣选TODO类型的提交", "CannotCherryPickMergeCommit": "不支持拣选合并类型的提交", "Donate": "捐助", "AskQuestion": "提问咨询", "PrevLine": "选择上一行", "NextLine": "选择下一行", "PrevHunk": "选择上一个区块", "NextHunk": "选择下一个区块", "PrevConflict": "选择上一个冲突", "NextConflict": "选择下一个冲突", "SelectPrevHunk": "选择顶部块", "SelectNextHunk": "选择底部块", "ScrollDown": "向下滚动", "ScrollUp": "向上滚动", "ScrollUpMainWindow": "向上滚动主面板", "ScrollDownMainWindow": "向下滚动主面板", "AmendCommitTitle": "修补(amend)提交", "AmendCommitPrompt": "您确定要使用暂存文件来修补此提交吗?", "AmendCommitWithConflictsMenuPrompt": "警告:您即将使用已解决的冲突来修正上一个已完成的提交。此时这样做很可能并非您所期望的。更可能的情况是,您只是想继续执行变基操作。\n\n您仍然想要修正前一个提交吗?", "AmendCommitWithConflictsContinue": "否,继续变基", "AmendCommitWithConflictsAmend": "是,修正上一个提交", "DropCommitTitle": "删除提交", "DropCommitPrompt": "您确定要删除此提交吗?", "DropUpdateRefPrompt": "您确定要删除选定的 update-ref 待办事项吗?除非中止变基,否则这是不可逆转的。", "PullingStatus": "正在拉取", "PushingStatus": "正在推送", "FetchingStatus": "正在抓取", "SquashingStatus": "正在压缩", "FixingStatus": "正在修正", "DeletingStatus": "正在删除", "DroppingStatus": "删除中...", "MovingStatus": "正在移动", "RebasingStatus": "正在变基", "MergingStatus": "合并中...", "LowercaseRebasingStatus": "变基中...", "LowercaseMergingStatus": "合并中...", "AmendingStatus": "正在修补", "CherryPickingStatus": "正在拣选", "UndoingStatus": "正在撤销", "RedoingStatus": "正在重做", "CheckingOutStatus": "正在检出", "CommittingStatus": "正在提交", "RevertingStatus": "还原中...", "CreatingFixupCommitStatus": "正在创建一个修复提交", "CommitFiles": "提交文件", "SubCommitsDynamicTitle": "提交 (%s)", "CommitFilesDynamicTitle": "比较文件差异 (%s)", "RemoteBranchesDynamicTitle": "远程分支 (%s)", "ViewItemFiles": "查看提交的文件", "ViewItemFilesTooltip": "查看所选项的修改文件", "CommitFilesTitle": "提交文件", "CheckoutCommitFileTooltip": "检出文件", "CanOnlyDiscardFromLocalCommits": "只能从本地提交中丢弃更改", "Remove": "删除", "DiscardOldFileChangeTooltip": "放弃对此文件的提交变更", "DiscardFileChangesTitle": "放弃文件变更", "DiscardFileChangesPrompt": "您确定要舍弃此提交对该文件的变更吗?如果此文件是在此提交中创建的,它将被删除", "CreateRepo": "当前目录不在 git 仓库中。是否在此目录创建一个新的 git 仓库?(y/n): ", "BareRepo": "您已经尝试在空仓库中打开Lazygit,但是Lazygit还不支持空仓库。打开最近的仓库吗?(y / n) ", "InitialBranch": "分支名称? (git的默认值为空): ", "NoRecentRepositories": "必须在git存储库中打开lazygit。没有有效的最近存储库。即将退出...", "IncorrectNotARepository": "'notARepository'的值不正确。它应该是“prompt”,“create”,“skip”或“quit”中的一个。", "AutoStashTitle": "自动存储?", "AutoStashPrompt": "您必须隐藏并弹出变更以使变更生效。自动执行?(enter/esc)", "StashPrefix": "自动隐藏变更 ", "Discard": "查看'放弃变更'选项", "DiscardChangesTitle": "放弃变更", "DiscardFileChangesTooltip": "查看选中文件的放弃变更选项", "Cancel": "取消", "DiscardAllChanges": "放弃所有变更", "DiscardUnstagedChanges": "放弃未暂存的变更", "DiscardAllChangesToAllFiles": "清空工作区", "DiscardAnyUnstagedChanges": "丢弃未暂存的变更", "DiscardUntrackedFiles": "丢弃未跟踪的文件", "DiscardStagedChanges": "丢弃已暂存的变更", "HardReset": "硬重置", "BranchDeleteTooltip": "查看本地/远程分支的删除选项", "TagDeleteTooltip": "查看本地/远程标签的删除选项", "Delete": "删除", "Reset": "重置", "ResetTooltip": "查看重置选项 (soft/mixed/hard) 用于重置到选择项", "ViewResetOptions": "查看重置选项", "FileResetOptionsTooltip": "查看工作树的重置选项(例如:清除工作树)。", "CreateFixupCommit": "为此提交创建修正", "CreateFixupCommitTooltip": "创建修正提交", "CreateAmendCommit": "创建 'amend!' 提交", "FixupMenu_Fixup": "修复提交", "FixupMenu_FixupTooltip": "允许你修复另一个提交并保持原本的提交信息", "FixupMenu_AmendWithChanges": "基于当前变动内容修改提交", "FixupMenu_AmendWithChangesTooltip": "允许你修复另一个提交并修改提交信息", "FixupMenu_AmendWithoutChanges": "修改提交(不含变动内容,类似reword)", "FixupMenu_AmendWithoutChangesTooltip": "允许您修改另一个提交的提交消息而不更改其内容", "SquashAboveCommitsTooltip": "压缩所选提交之上或当前分支的所有 “fixup!” 提交(自动压缩)。", "SquashCommitsAboveSelectedTooltip": "压缩当前选中提交下的所有修复提交(自动压缩)", "SquashCommitsInCurrentBranchTooltip": "压缩当前分支中的所有修复提交(自动压缩)", "SquashAboveCommits": "应用该修复提交", "SquashCommitsInCurrentBranch": "在当前分支", "SquashCommitsAboveSelectedCommit": "在选定提交之上", "CannotSquashCommitsInCurrentBranch": "在当前分支中无法压缩提交:因为分离HEAD提交是一个合并提交或者已经存在于主分支中", "ExecuteShellCommand": "执行 Shell 命令", "ShellCommand": "Shell 命令:", "CommitChangesWithoutHook": "提交变更而无需预先提交钩子", "ResetTo": "重置为", "ResetSoftTooltip": "将 HEAD 重置为所选提交,并将当前提交和所选提交之间的更改保留为已暂存更改。", "ResetMixedTooltip": "将 HEAD 重置为所选提交,并将当前提交和所选提交之间的更改保留为未暂存的更改。", "ResetHardTooltip": "将 HEAD 重置为所选提交,并丢弃当前提交和所选提交之间的所有更改,以及工作树中的所有当前修改。", "PressEnterToReturn": "按下 Enter 键返回 lazygit", "ViewStashOptions": "查看贮藏选项", "ViewStashOptionsTooltip": "查看贮藏选项(例如:贮藏所有、贮藏已暂存变更、贮藏未暂存变更)", "Stash": "贮藏", "StashTooltip": "贮藏所有变更.若要使用其他贮藏变体,请使用查看贮藏选项快捷键", "StashAllChanges": "将所有变更加入贮藏", "StashStagedChanges": "贮藏已暂存变更", "StashAllChangesKeepIndex": "将已暂存的变更加入贮藏", "StashUnstagedChanges": "贮藏未暂存变更", "StashIncludeUntrackedChanges": "贮藏所有变更,包括未跟踪的文件", "StashOptions": "贮藏选项", "NotARepository": "错误:必须在 git 仓库中运行", "WorkingDirectoryDoesNotExist": "错误:当前工作目录不存在", "Jump": "跳到面板", "ScrollLeftRight": "左右滚动", "ScrollLeft": "向左滚动", "ScrollRight": "向右滚动", "DiscardPatch": "丢弃补丁", "DiscardPatchConfirm": "您一次只能通过一个提交或贮藏条目构建补丁。需要放弃当前补丁吗?", "CantPatchWhileRebasingError": "处于合并或变基状态时,您无法构建修补程序或运行修补程序命令", "ToggleAddToPatch": "补丁中包含的切换文件", "ToggleAddToPatchTooltip": "切换文件是否包含在自定义补丁中。请参阅 {{.doc}}。", "ToggleAllInPatch": "操作所有文件", "ToggleAllInPatchTooltip": "添加或删除所有提交中的文件到自定义的补丁中。请参阅 {{.doc}}。", "UpdatingPatch": "正在更新补丁", "ViewPatchOptions": "查看自定义补丁选项", "PatchOptionsTitle": "补丁选项", "NoPatchError": "尚未创建补丁。你可以在提交中的文件上按下“空格”或使用“回车”添加其中的特定行以开始构建补丁", "EmptyPatchError": "补丁还是空的。首先将一些文件或行添加到您的补丁中。", "EnterCommitFile": "输入文件以将所选行添加到补丁中(或切换目录折叠)", "EnterCommitFileTooltip": "如果已选择一个文件,则Enter进入该文件,以便您可以向自定义补丁添加/删除单独的行。如果选择了目录,则切换目录。", "ExitCustomPatchBuilder": "退出逐行模式", "EnterUpstream": "以这种格式输入上游:'<远程仓库> <分支名称>'", "InvalidUpstream": "上游格式无效,格式应当为:' '", "ReturnToRemotesList": "返回远程仓库列表", "NewRemote": "添加新的远程仓库", "NewRemoteName": "新远程仓库名称:", "NewRemoteUrl": "新远程仓库 URL:", "ViewBranches": "查看分支", "EditRemoteName": "输入远程仓库 {{.remoteName}} 的新名称:", "EditRemoteUrl": "输入远程仓库 {{.remoteName}} 的新 URL:", "RemoveRemote": "删除远程", "RemoveRemoteTooltip": "删除选中的远程。从远程跟踪远程分支的任何本地分支都不会受到影响。", "DeleteRemoteBranch": "删除远程分支", "DeleteRemoteBranches": "删除原创分支", "DeleteRemoteBranchTooltip": "从远程删除远程分支。", "DeleteLocalAndRemoteBranch": "删除本地和远程分支", "DeleteLocalAndRemoteBranches": "删除本地和远程分支", "SetAsUpstream": "设置为上游", "SetAsUpstreamTooltip": "设置为检出分支的上游", "SetUpstream": "设置为检出分支的上游", "UnsetUpstream": "取消已选分支的上游", "ViewDivergenceFromUpstream": "查看与上游的差异", "ViewDivergenceFromBaseBranch": "查看主分支({{.baseBranch}})与上游的差异", "CouldNotDetermineBaseBranch": "无法确定主分支", "DivergenceSectionHeaderLocal": "本地", "DivergenceSectionHeaderRemote": "远端", "ViewUpstreamResetOptions": "重置已检出的分支到上游{{.upstream}}", "ViewUpstreamResetOptionsTooltip": "查看用于将检出分支重置到 {{upstream}} 的选项。注意:这不会将选定的分支重置到上游,而是将检出的分支重置到上游。", "ViewUpstreamRebaseOptions": "将检出分支的上游重新设置为 {{.upstream}}", "ViewUpstreamRebaseOptionsTooltip": "查看将检出分支变基到 {{upstream}} 的选项。注意:这不会将所选分支重新设置为上游,而是将检出分支重新设置为上游。", "UpstreamGenericName": "所选分支的上游", "SetUpstreamTitle": "设置上游分支", "EditRemoteTooltip": "编辑远程仓库", "TagCommit": "标签提交", "TagCommitTooltip": "创建一个新标签指向所选提交。你可以在弹窗中输入标签名称和描述(可选)。", "TagMenuTitle": "创建标签", "TagNameTitle": "标签名称", "TagMessageTitle": "标签消息", "LightweightTag": "轻量标签", "AnnotatedTag": "附注标签", "DeleteTagTitle": "要删除 '{{.tagName}}' 标签?", "DeleteLocalTag": "删除本地标签", "DeleteRemoteTag": "删除远程标签", "DeleteLocalAndRemoteTag": "删除本地和远程标签", "SelectRemoteTagUpstream": "被删除标签'{{.tagName}}'的远端", "DeleteRemoteTagPrompt": "你确定要从'{{.upstream}}'中删除远程标签'{{.tagName}}'吗?", "RemoteTagDeletedMessage": "远程标签已删除", "PushTagTitle": "将 {{.tagName}} 推送到远程仓库:", "PushTag": "推送标签", "PushTagTooltip": "推送选择的标签到远端。你将在弹窗中选择一个远端。", "NewTag": "创建标签", "NewTagTooltip": "基于当前提交创建一个新标签。你将在弹窗中输入标签名称和描述(可选)。", "CreatingTag": "创建标签", "ForceTag": "强制标记标签", "ForceTagPrompt": "该标签‘{{.tagName}}’已存在。请按{{.cancelKey}}取消,或者按{{.confirmKey}}覆盖它。", "FetchRemoteTooltip": "抓取远程仓库", "FetchingRemoteStatus": "抓取远程仓库中", "CheckoutCommit": "检出提交", "CheckoutCommitTooltip": "检出所选择的提交作为分离HEAD。", "SureCheckoutThisCommit": "您确定要检出此提交吗?", "GitFlowOptions": "显示 git-flow 选项", "NotAGitFlowBranch": "这似乎不是 git flow 分支", "NewBranchNamePrompt": "输入分支的新名称", "IgnoreTracked": "忽略跟踪文件", "ExcludeTracked": "排除跟踪文件", "IgnoreTrackedPrompt": "您确定要忽略已跟踪的文件吗?", "ExcludeTrackedPrompt": "您确定要排除已跟踪的文件吗?", "ViewResetToUpstreamOptions": "查看上游重置选项", "NextScreenMode": "下一屏模式(正常/半屏/全屏)", "PrevScreenMode": "上一屏模式", "StartSearch": "开始搜索", "StartFilter": "通过文本过滤当前视图", "Panel": "面板", "Keybindings": "按键绑定", "KeybindingsLegend": "图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b", "KeybindingsMenuSectionLocal": "本地", "KeybindingsMenuSectionGlobal": "全局", "KeybindingsMenuSectionNavigation": "导航", "RenameBranch": "重命名分支", "Upstream": "上游", "UpstreamTooltip": "查看所选分支的上游选项,例如设置/取消设置上游和重置为上游。", "BranchUpstreamOptionsTitle": "上游选项", "ViewBranchUpstreamOptions": "查看上游选项", "ViewBranchUpstreamOptionsTooltip": "查看与分支上游相关的选项,例如设置/取消设置上游和重置为上游。", "UpstreamNotSetError": "所选分支没有上游(或者上游没有存储在本地)", "NewGitFlowBranchPrompt": "新的 {{.branchType}} 名称:", "RenameBranchWarning": "该分支正在跟踪远程仓库。此操作将仅会重命名本地分支名称,而不会重命名远程分支的名称。确定继续?", "OpenKeybindingsMenu": "打开菜单", "ResetCherryPick": "重置已拣选(复制)的提交", "NextTab": "下一个标签", "PrevTab": "上一个标签", "CantUndoWhileRebasing": "进行基础调整时无法撤消", "CantRedoWhileRebasing": "变基时无法重做", "MustStashWarning": "将补丁拉出到索引中需要存储和取消存储所做的变更。如果出现问题,您将可以从存储中访问文件。继续?", "MustStashTitle": "必须保存进度", "ConfirmationTitle": "确认面板", "PrevPage": "上一页", "NextPage": "下一页", "GotoTop": "滚动到顶部", "GotoBottom": "滚动到底部", "FilteringBy": "过滤依据", "ResetInParentheses": "(重置)", "OpenFilteringMenu": "查看按路径过滤选项", "OpenFilteringMenuTooltip": "查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。", "FilterBy": "过滤", "ExitFilterMode": "停止按路径过滤", "FilterPathOption": "输入要过滤的路径", "FilterAuthorOption": "输入作者进行过滤", "EnterFileName": "输入路径:", "EnterAuthor": "输入作者:", "FilteringMenuTitle": "正在过滤", "WillCancelExistingFilterTooltip": "注意:这将取消现有的过滤器", "MustExitFilterModeTitle": "命令不可用", "MustExitFilterModePrompt": "命令在过滤模式下不可用。退出过滤模式?", "Diff": "差异", "EnterRefToDiff": "输入 ref 以 diff", "EnterRefName": "输入 ref:", "ExitDiffMode": "退出差异模式", "DiffingMenuTitle": "正在 diff", "SwapDiff": "反向 diff", "ViewDiffingOptions": "打开 diff 菜单", "ViewDiffingOptionsTooltip": "查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。", "OpenCommandLogMenu": "打开命令日志菜单", "OpenCommandLogMenuTooltip": "查看命令日志的选项,例如显示/隐藏命令日志以及聚焦命令日志", "ShowingGitDiff": "显示输出:", "CommitDiff": "比较提交差异", "CopyCommitHashToClipboard": "将提交的 hash 复制到剪贴板", "CommitHash": "提交的 hash", "CommitURL": "提交URL", "CopyCommitMessageToClipboard": "将提交消息复制到剪贴板", "PasteCommitMessageFromClipboard": "从剪贴板粘贴提交信息", "SurePasteCommitMessage": "粘贴将覆盖当前提交消息,继续吗?", "CommitMessage": "提交信息", "CommitSubject": "提交主题", "CommitAuthor": "作者", "CommitTags": "提交标签", "CopyCommitAttributeToClipboard": "复制提交属性到剪贴板", "CopyCommitAttributeToClipboardTooltip": "复制提交属性到剪贴板(例如,hash、URL、diff、消息、作者)。", "CopyBranchNameToClipboard": "将分支名称复制到剪贴板", "CopyTagToClipboard": "将标签复制到剪贴板", "CopyPathToClipboard": "将文件名复制到剪贴板", "CommitPrefixPatternError": "提交前缀模式错误", "CopySelectedTextToClipboard": "将选中文本复制到剪贴板", "NoFilesStagedTitle": "没有暂存文件", "NoFilesStagedPrompt": "您尚未暂存任何文件。提交所有文件?", "BranchNotFoundTitle": "找不到分支", "BranchNotFoundPrompt": "找不到分支。创建一个新分支命名为:", "BranchUnknown": "未知的分支", "DiscardChangeTitle": "取消暂存选中的行", "DiscardChangePrompt": "您确定要删除所选的行(git reset)吗?这是不可逆的。\n要禁用此对话框,请将 'gui.skipDiscardChangeWarning' 的配置键设置为 true", "CreateNewBranchFromCommit": "从提交创建新分支", "BuildingPatch": "正在构建补丁", "ViewCommits": "查看提交", "RunningCustomCommandStatus": "正在运行自定义命令", "SubmoduleStashAndReset": "存放未提交的子模块变更和更新", "AndResetSubmodules": "和重置子模块", "EnterSubmoduleTooltip": "输入子模块", "Enter": "进入", "CopySubmoduleNameToClipboard": "将子模块名称复制到剪贴板", "RemoveSubmodule": "删除子模块", "RemoveSubmoduleTooltip": "删除选定的子模块及其相应的目录", "RemoveSubmodulePrompt": "您确定要删除子模块 '%s' 及其对应的目录吗?这是不可逆的。", "ResettingSubmoduleStatus": "正在重置子模块", "NewSubmoduleName": "新的子模块名称:", "NewSubmoduleUrl": "新的子模块 URL:", "NewSubmodulePath": "新的子模块路径:", "NewSubmodule": "添加新的子模块", "AddingSubmoduleStatus": "添加子模块", "UpdateSubmoduleUrl": "更新子模块 '%s' 的 URL", "UpdatingSubmoduleUrlStatus": "更新 URL 中", "EditSubmoduleUrl": "更新子模块 URL", "InitializingSubmoduleStatus": "正在初始化子模块", "InitSubmoduleTooltip": "初始化子模块", "Update": "更新", "Initialize": "初始化", "SubmoduleUpdateTooltip": "更新子模块", "UpdatingSubmoduleStatus": "正在更新子模块", "BulkInitSubmodules": "批量初始化子模块", "BulkUpdateSubmodules": "批量更新子模块", "BulkDeinitSubmodules": "批量反初始化子模块", "ViewBulkSubmoduleOptions": "查看批量子模块选项", "BulkSubmoduleOptions": "批量子模块选项", "RunningCommand": "运行命令", "SubCommitsTitle": "子提交", "SubmodulesTitle": "子模块", "NavigationTitle": "列表面板导航", "SuggestionsCheatsheetTitle": "意见建议", "SuggestionsTitle": "意见建议(按 %s 键以切换焦点)", "SuggestionsSubtitle": "(按 %s 键进行删除, %s 键进行编辑)", "ExtrasTitle": "附加", "PushingTagStatus": "推送标签", "PullRequestURLCopiedToClipboard": "拉取请求网址已复制到剪贴板", "CommitDiffCopiedToClipboard": "复制提交差异到剪贴板", "CommitURLCopiedToClipboard": "复制提交URL到剪贴板", "CommitMessageCopiedToClipboard": "复制提交消息到剪贴板", "CommitSubjectCopiedToClipboard": "复制提交主题到剪贴板", "CommitAuthorCopiedToClipboard": "复制作者到剪贴板", "CommitHasNoTags": "提交没有标签", "PatchCopiedToClipboard": "已复制补丁内容到剪贴板", "CopiedToClipboard": "复制到剪贴板", "ErrCannotEditDirectory": "无法编辑目录:您只能编辑单个文件", "ErrStageDirWithInlineMergeConflicts": "无法 暂存/取消暂存 包含具有内联合并冲突的文件的目录。请先解决合并冲突", "ErrRepositoryMovedOrDeleted": "找不到仓库。它可能已被移动或删除 ¯\\_(ツ)_/¯", "ErrWorktreeMovedOrRemoved": "找不到工作树,它可能被删除或者移走了。 ¯\\\\_(ツ)_/¯", "CommandLog": "命令日志", "ToggleShowCommandLog": "切换 显示/隐藏 命令日志", "FocusCommandLog": "焦点命令日志", "CommandLogHeader": "您可以通过按 '%s' 隐藏或集中显示该面板,或使用 `gui.showCommandLog: false`\n将其永久隐藏在您的配置中", "RandomTip": "随机小提示", "SelectParentCommitForMerge": "选择父提交进行合并", "ToggleWhitespaceInDiffView": "切换是否在差异视图中显示空白字符差异", "ToggleWhitespaceInDiffViewTooltip": "切换是否在diff视图中显示空白更改", "IgnoreWhitespaceDiffViewSubTitle": "(忽略空白)", "IgnoreWhitespaceNotSupportedHere": "该视图不支持忽略空白", "IncreaseContextInDiffView": "扩大差异视图中显示的上下文范围", "IncreaseContextInDiffViewTooltip": "增加diff视图中围绕更改显示的上下文数量", "DecreaseContextInDiffView": "缩小差异视图中显示的上下文范围", "DecreaseContextInDiffViewTooltip": "减少diff视图中围绕更改显示的上下文数量", "DiffContextSizeChanged": "将diff上下文大小更改为%d", "CreatePullRequestOptions": "创建拉取请求选项", "DefaultBranch": "默认分支", "SelectBranch": "选择分支", "NoValidRemoteName": "名为 '%s' 的远程名称不存在", "CreatePullRequest": "创建拉取请求", "SelectConfigFile": "选择配置文件", "NoConfigFileFoundErr": "找不到配置文件", "LoadingFileSuggestions": "正在加载文件建议", "LoadingCommits": "正在加载提交", "MustSpecifyOriginError": "指定分支时,必须同时指定远程", "GitOutput": "Git 输出:", "GitCommandFailed": "Git 命令执行失败。查看命令日志了解详情(使用 %s 打开)", "AbortTitle": "放弃 %s", "AbortPrompt": "您确定要放弃当前 %s 吗?", "OpenLogMenu": "打开日志菜单", "OpenLogMenuTooltip": "查看提交日志的选项,例如更改排序顺序、隐藏 git graph、显示整个 git graph。", "LogMenuTitle": "提交日志选项", "ToggleShowGitGraphAll": "切换显示完整 git 分支图(向 `git log` 命令传入 `--all` 选项)", "ShowGitGraph": "显示 git 分支图", "SortOrder": "排序", "SortAlphabetical": "字母顺序", "SortByDate": "日期大小", "SortByRecency": "新旧程度", "SortBasedOnReflog": "(基于reflog)", "SortCommits": "提交排序", "CantChangeContextSizeError": "无法在补丁构建模式下变更上下文,因为我们在发布该功能时懒得支持它。 如果你真的想要这么做,请告诉我们!", "OpenCommitInBrowser": "在浏览器中打开提交", "ViewBisectOptions": "查看二分查找选项", "ConfirmRevertCommit": "你确定要恢复{{.selectedCommit}}?", "RewordInEditorTitle": "在编辑器中重写", "RewordInEditorPrompt": "您确定要在编辑器中重写此提交吗?", "HardResetAutostashPrompt": "您确定要硬重置到 '%s' 吗?如有必要,将执行自动贮藏。", "SoftResetPrompt": "你确定要软重置到 '%s' 吗?", "UpstreamGone": "(上游不存在)", "NukeDescription": "如果您想让工作树中的所有更改消失,可以这样做。如果存在受污染的子模块更改,这会将这些更改贮藏在子模块中。", "DiscardStagedChangesDescription": "这将创建一个仅包含已暂存更改的新贮藏条目,然后这些更改删除,以便工作树仅保留未暂存的更改。", "EmptyOutput": "", "Patch": "补丁", "CustomPatch": "自定义补丁", "CommitsCopied": "已复制这些提交", "CommitCopied": "已复制提交", "ResetPatch": "重置补丁", "ResetPatchTooltip": "清理当前补丁", "ApplyPatch": "应用补丁", "ApplyPatchTooltip": "应用当前补丁到工作树中", "ApplyPatchInReverse": "反向应用补丁", "ApplyPatchInReverseTooltip": "反向应用当前补丁到工作树中", "RemovePatchFromOriginalCommit": "从原来的提交 (%s) 中删除补丁", "RemovePatchFromOriginalCommitTooltip": "从这些提交中删除该补丁。这是通过在提交时启动交互式变基,反向应用补丁,然后继续变基来实现的。如果之后的提交依赖于补丁,您可能需要解决冲突。", "MovePatchOutIntoIndex": "将补丁移出到索引中", "MovePatchOutIntoIndexTooltip": "将补丁从提交中移出并移入到索引中。这是通过在提交时启动交互式变基、反向应用补丁、继续变基直至完成,然后将补丁应用到索引来实现的。如果之后的提交依赖于补丁,您可能需要解决冲突。", "MovePatchIntoNewCommit": "引入补丁到新提交中", "MovePatchIntoNewCommitTooltip": "将补丁从提交中移出并移至位于原始提交之上的新提交中。这是通过在原始提交处启动交互式变基,反向应用补丁,然后将补丁应用到索引并将其作为新提交提交,然后继续变基直至完成来实现的。如果以后的提交依赖于补丁,您可能需要解决冲突。", "MovePatchToSelectedCommit": "移动补丁到所选提交 (%s)", "MovePatchToSelectedCommitTooltip": "将补丁从其原始提交修改到选定的提交中。 实现这一点的方法是在原始提交时启动交互式重置,反向应用补丁, 然后在应用补丁和修改选定的提交之前,继续将其重新建立到选定的提交上。 重置将继续完成。如果源代码和目标代码提交之间的提交取决于补丁,您可能需要解决冲突。", "CopyPatchToClipboard": "复制补丁到剪贴板", "NoMatchesFor": "%s %s 没有匹配项", "MatchesFor": "正在匹配'%s' (%d of %d) %s", "SearchKeybindings": "%s: 下一个匹配项, %s: 上一个匹配项, %s: 退出搜索模式", "SearchPrefix": "搜索: ", "FilterPrefix": "过滤: ", "ExitSearchMode": "%s:退出搜索模式", "ExitTextFilterMode": "%s:退出过滤模式", "Switch": "切换", "SwitchToWorktree": "切换至工作树", "SwitchToWorktreeTooltip": "切换到选中的工作树", "AlreadyCheckedOutByWorktree": "该分支由工作树 {{.worktreeName}} 检出,您想切换到该工作树吗?", "BranchCheckedOutByWorktree": "分支 {{.branchName}} 由工作树 {{.worktreeName}} 检出", "DetachWorktreeTooltip": "这将在工作树上运行“git checkout --detach”,以便它停止占用分支,但工作树的工作树将保持不变。", "Switching": "正在切换", "RemoveWorktree": "移除工作区", "RemoveWorktreeTitle": "移除工作区", "DetachWorktree": "分离工作树", "DetachingWorktree": "正在分离工作树", "WorktreesTitle": "工作区", "WorktreeTitle": "工作区", "RemoveWorktreePrompt": "你确定要删除工作树 {{.worktreeName}}' ?", "ForceRemoveWorktreePrompt": "'{{.worktreeName}}' 包含已修改或未跟踪的文件(说实话,它可能包含两者)。您确定要删除它吗?", "RemovingWorktree": "正在删除工作树", "AddingWorktree": "添加工作区", "CantDeleteCurrentWorktree": "您不能删除当前的工作树!", "AlreadyInWorktree": "您已经在选中的工作树", "CantDeleteMainWorktree": "您不能移除主工作树!", "NoWorktreesThisRepo": "没有工作区", "MissingWorktree": "(缺失)", "MainWorktree": "(主要)", "NewWorktree": "新建工作树", "NewWorktreePath": "新建工作树路径", "NewWorktreeBase": "新建工作树基于ref", "RemoveWorktreeTooltip": "删除选定的工作树。这将删除工作树的目录以及 .git 目录中有关工作树的元数据。", "BranchNameCannotBeBlank": "分支名称不能为空", "NewBranchName": "新分支名称", "NewBranchNameLeaveBlank": "新分支名称(为空则默认检出为 {{.default}})", "ViewWorktreeOptions": "查看工作区选项", "CreateWorktreeFrom": "从 {{.ref}} 创建工作树", "CreateWorktreeFromDetached": "从 {{.ref}} 创建工作树(分离)", "LcWorktree": "工作区", "ChangingDirectoryTo": "将目录更改为 {{.path}}", "Name": "名称", "Branch": "分支", "Path": "路径", "MarkedBaseCommitStatus": "已标记一个主提交用于变基", "MarkAsBaseCommit": "标记一个主提交用于变基", "MarkAsBaseCommitTooltip": "选择下一次变基的主提交。当您变基到一个分支时,只有高于主提交的提交才会被引入。这使用“git rebase --onto”命令。", "MarkedCommitMarker": "↑↑↑ 将从这里开始变基 ↑↑↑", "NoCopiedCommits": "未复制提交", "DisabledMenuItemPrefix": "不可用: ", "QuickStartInteractiveRebase": "开始交互式变基", "QuickStartInteractiveRebaseTooltip": "为分支上的提交启动交互式变基。这将包括从 HEAD 提交到第一个合并提交或主分支提交的所有提交。\n如果您想从所选提交启动交互式变基,请按 `{{.editKey}}`。", "CannotQuickStartInteractiveRebase": "无法启动交互式变基:HEAD 提交是合并提交或存在于主分支上,因此没有适当的主提交来启动变基。您可以通过选择提交并按 `{{.editKey}}` 从特定提交启动交互式变基。", "ToggleRangeSelect": "切换拖动选择", "RangeSelectUp": "向上扩展选择范围", "RangeSelectDown": "向下扩展选择范围", "RangeSelectNotSupported": "该操作不支持范围选择,请选择单个项目", "NoItemSelected": "没有条目被选中", "SelectedItemIsNotABranch": "选中的条目不是一个分支", "SelectedItemDoesNotHaveFiles": "选中的条目中没有", "OldCherryPickKeyWarning": "The 'c' key is no longer the default key for copying commits to cherry pick. Please use `{{.copy}}` instead (and `{{.paste}}` to paste). The reason for this change is that the 'v' key for selecting a range of lines when staging is now also used for selecting a range of lines in any list view, meaning that we needed to find a new key for pasting commits, and if we're going to now use `{{.paste}}` for pasting commits, we may as well use `{{.copy}}` for copying them. If you want to configure the keybindings to get the old behaviour, set the following in your config:\n\nkeybinding:\n universal:\n toggleRangeSelect: \n commits:\n cherryPickCopy: 'c'\n pasteCommits: 'v'", "CommandDoesNotSupportOpeningInEditor": "该命令不支持切换到编辑器", "Actions": { "CheckoutCommit": "检出提交", "CheckoutBranchAtCommit": "检出分支 '%s'", "CheckoutTag": "检出标签", "CheckoutBranch": "检出分支", "CheckoutBranchOrCommit": "检出分支或提交", "ForceCheckoutBranch": "强制检出分支", "DeleteLocalBranch": "删除本地分支", "Merge": "合并", "SquashMerge": "压缩合并", "RebaseBranch": "变基分支", "RenameBranch": "重命名分支", "CreateBranch": "建立分支", "FastForwardBranch": "快进分支", "CherryPick": "(拣选) 粘贴提交", "CheckoutFile": "检出文件", "DiscardOldFileChange": "放弃旧文件变更", "SquashCommitDown": "向下压缩提交", "FixupCommit": "修正提交", "RewordCommit": "改写提交", "DropCommit": "删除提交", "EditCommit": "编辑提交", "AmendCommit": "提交修补", "ResetCommitAuthor": "重设作者", "SetCommitAuthor": "设置作者", "AddCommitCoAuthor": "添加其他的提交作者", "RevertCommit": "还原提交", "CreateFixupCommit": "创建修正提交", "SquashAllAboveFixupCommits": "压缩以上所有的修正提交", "MoveCommitUp": "上移提交", "MoveCommitDown": "下移提交", "CopyCommitMessageToClipboard": "将提交消息复制到剪贴板", "CopyCommitSubjectToClipboard": "复制提交主题到剪贴板", "CopyCommitDiffToClipboard": "复制提交差异到剪贴板", "CopyCommitHashToClipboard": "复制完整的提交哈希到剪贴板", "CopyCommitURLToClipboard": "复制提交URL到剪贴板", "CopyCommitAuthorToClipboard": "复制提交作者到剪贴板", "CopyCommitAttributeToClipboard": "复制到剪贴板", "CopyPatchToClipboard": "复制路径到剪贴板", "CustomCommand": "自定义命令", "DiscardAllChangesInDirectory": "丢弃目录中的所有变更", "DiscardUnstagedChangesInDirectory": "丢弃目录中未暂存的变更", "DiscardAllChangesInFile": "丢弃文件中的所有变更", "DiscardAllUnstagedChangesInFile": "丢弃文件中所有未暂存的变更", "StageFile": "暂存文件", "StageResolvedFiles": "合并冲突已解决的已暂存文件", "UnstageFile": "取消暂存文件", "UnstageAllFiles": "取消暂存所有文件", "StageAllFiles": "暂存所有文件", "IgnoreExcludeFile": "忽略文件", "IgnoreFileErr": "无法忽略 .gitignore", "ExcludeFile": "排除文件", "ExcludeGitIgnoreErr": "无法排除.gitignore", "Commit": "提交(Commit)", "EditFile": "编辑文件", "Push": "推送(Push)", "Pull": "拉取(Pull)", "OpenFile": "打开文件", "StashAllChanges": "贮藏所有变更", "StashAllChangesKeepIndex": "贮藏所有更改并保持索引", "StashStagedChanges": "贮藏暂存的变更", "StashUnstagedChanges": "贮藏所有未暂存更改", "StashIncludeUntrackedChanges": "贮藏所有更改包括未跟踪的文件", "GitFlowFinish": "git flow 结果", "GitFlowStart": "git flow 开始", "CopyToClipboard": "复制到剪贴板", "CopySelectedTextToClipboard": "将选中文本复制到剪贴板", "RemovePatchFromCommit": "从提交中删除补丁", "MovePatchToSelectedCommit": "将补丁移动到选定的提交", "MovePatchIntoIndex": "将补丁移到索引", "MovePatchIntoNewCommit": "将补丁移到新提交中", "DeleteRemoteBranch": "删除远程分支", "SetBranchUpstream": "设置分支上游", "AddRemote": "添加远程", "RemoveRemote": "移除远程", "UpdateRemote": "更新远程", "ApplyPatch": "应用补丁", "Stash": "贮藏(Stash)", "RenameStash": "重命名贮藏", "RemoveSubmodule": "删除子模块", "ResetSubmodule": "重置子模块", "AddSubmodule": "添加子模块", "UpdateSubmoduleUrl": "更新子模块 URL", "InitialiseSubmodule": "初始化子模块", "BulkInitialiseSubmodules": "批量初始化子模块", "BulkUpdateSubmodules": "批量更新子模块", "BulkDeinitialiseSubmodules": "批量取消初始化子模块", "UpdateSubmodule": "更新子模块", "CreateLightweightTag": "创建轻量标签", "CreateAnnotatedTag": "创建附注标签", "DeleteLocalTag": "删除本地标签", "DeleteRemoteTag": "删除远程标签", "PushTag": "推送标签", "NukeWorkingTree": "Nuke 工作树", "DiscardUnstagedFileChanges": "放弃未暂存的文件变更", "RemoveUntrackedFiles": "删除未跟踪的文件", "RemoveStagedFiles": "移除贮藏文件", "SoftReset": "软重置", "MixedReset": "混合重置", "HardReset": "硬重置", "Undo": "撤销", "Redo": "重做", "CopyPullRequestURL": "复制拉取请求 URL", "OpenDiffTool": "打开差异比较工具", "OpenMergeTool": "打开合并工具", "OpenCommitInBrowser": "在浏览器中打开提交", "OpenPullRequest": "在浏览器中打开拉取请求", "StartBisect": "开始二分查找(Bisect)", "ResetBisect": "重置二分查找", "BisectSkip": "二分查找跳过", "BisectMark": "二分查找标记", "RemoveWorktree": "移除工作区", "AddWorktree": "添加工作区" }, "Bisect": { "MarkStart": "将 %s 标记为 %s (start bisect)", "ResetTitle": "重置 'git bisect'", "ResetPrompt": "您确定要重置 'git bisect' 吗?", "ResetOption": "重置二分查找", "ChooseTerms": "二选一", "OldTermPrompt": "旧/好的提交:", "NewTermPrompt": "新/坏的提交:", "BisectMenuTitle": "二分查找", "Mark": "将 %s 标记为 %s", "SkipCurrent": "跳过 %s", "SkipSelected": "跳过所选提交(%s)", "CompleteTitle": "二分查找完成", "CompletePrompt": "二分查找完成!以下提交引入了此变更:\n\n%s\n\n您现在要重置 'git bisect' 吗?", "CompletePromptIndeterminate": "二分查找完成!一些提交被跳过了,所以下列提交中的任何一个都可能引入了此变更:\n\n%s\n\n您现在要重置 'git bisect' 吗?", "Bisecting": "二分查找中" }, "Log": { "EditRebase": "开始从 '{{.ref}}' 进行交互式变基", "MoveCommitUp": "向下移动 TODO: '{{.shortHash}}'", "MoveCommitDown": "向上移动 TODO: '{{.shortHash}}'", "CherryPickCommits": "拣选提交:\n'{{.commitLines}}'", "HandleUndo": "撤销最后一次的冲突解决方案", "HandleMidRebaseCommand": "将提交 {{.shortHash}} 的变基操作更新为 '{{.action}}'", "RemoveFile": "正在删除路径 '{{.path}}'", "CopyToClipboard": "正在复制 '{{.str}}' 到剪贴板", "Remove": "删除 '{{.filename}}'", "CreateFileWithContent": "正在创建文件 '{{.path}}'", "AppendingLineToFile": "将 '{{.line}}' 附加到文件 '{{.filename}}'", "EditRebaseFromBaseCommit": "开始从'{{.baseCommit}}'进行交互式变基到'{{.targetBranchName}}‘" }, "BreakingChangesTitle": "重大变化", "BreakingChangesMessage": "您正在更新到 lazygit 的新版本,其中含有中断的更改。请阅读下面的说明,并在必要时更新您的配置。\n欲了解更多信息,请参阅 的完整版本说明。", "BreakingChangesByVersion": { "0.41.0": "- 当您按“g”调出 git 重置菜单时,“mixed”选项现在是第一个也是默认选项,而不是“soft”。这是因为“mixed”是最常用的选项。\n- 提交消息面板现在默认自动硬换行(即,当您到达页边距时,它会添加换行符)。您可以像这样调整配置:\n\ngit:\n commit:\n autoWrapCommitMessage: true\n autoWrapWidth: 72\n\n- “v”键已在暂存视图中用于启动范围选择,但现在您可以使用它在任何视图中启动范围选择。不幸的是,这与粘贴提交(cherry-pick)的“v”键绑定冲突,因此现在粘贴提交是通过“shift+V”完成的,为了一致性,复制提交现在是通过“shift+C”而不是“c”。请注意,“v”键绑定只是启动范围选择的一种方法:您可以使用 shift+向上/向下箭头。因此,如果您想配置cherry-pick键绑定以获得旧行为,请在配置中设置以下内容:\n\nkeybinding:\n universal:\n toggleRangeSelect: \n commits:\n cherryPickCopy: 'c'\n pasteCommits: 'v'\n\n- 使用“shift-S”压缩修复现在会弹出一个菜单,默认选项是压缩分支中的所有修复提交。仅压缩所选提交之上的修复提交的原始行为仍然可以作为该菜单中的第二个选项使用。\n- Push/pull/fetch 加载状态现在显示在分支上,而不是在弹出窗口中。这允许您例如并行获取多个分支并查看每个分支的状态。\n- 提交视图中的 git 日志图现在始终默认显示(以前仅在视图最大化时显示)。如果你发现这太冗余了,你可以通过 ctrl+L -> 'Show git graph' -> 'when maximized' 将其改回来\n- 在远程分支上按空格用于显示输入新本地分支名称的提示,以从远程分支检出。现在它只是直接检查远程分支,让您在同名的新本地分支或分离的头之间进行选择。旧的行为仍然可以通过“n”键绑定使用。\n- 默认情况下,过滤(例如按“/”时)不太模糊;它现在只匹配子字符串。多个子字符串可以通过用空格分隔来匹配。如果您想恢复到旧的行为,请在配置中设置以下内容:\n\ngui:\n filterMode: 'fuzzy'\n\t ", "0.44.0": "- gui.branchColors 配置选项已弃用;其将在未来的版本中被移除。请使用 gui.branchColorPatterns 代替。\n- 以 \"feature/\",\"bugfix/\" 和 \"hotfix/\" 开头的分支自动上色已移除;如果你想要保留该功能,可以通过设置新的 gui.branchColorPatterns 选项来启用。" } } lazygit-0.50.0+ds1/pkg/i18n/translations/zh-TW.json000066400000000000000000001126701500612110400216640ustar00rootroot00000000000000{ "NotEnoughSpace": "無足夠空間顯示面板", "DiffTitle": "差異", "FilesTitle": "檔案", "BranchesTitle": "分支", "CommitsTitle": "提交", "StashTitle": "收藏 (Stash)", "SnakeTitle": "貪食蛇", "EasterEgg": "彩蛋", "UnstagedChanges": "未預存變更", "StagedChanges": "已預存變更", "MainTitle": "主要", "StagingTitle": "主面板(預存)", "MergingTitle": "主面板(合併)", "SquashMergeUncommittedTitle": "壓縮合併但不提交", "SquashMergeCommittedTitle": "壓縮合併並提交", "NormalTitle": "主面板(一般)", "LogTitle": "版本記錄", "CommitSummary": "提交摘要", "CredentialsUsername": "使用者名稱", "CredentialsPassword": "密碼", "CredentialsPassphrase": "SSH 金鑰密語", "CredentialsPIN": "SSH 金鑰 PIN 碼", "PassUnameWrong": "密碼、密語或使用者名稱錯誤", "Commit": "提交變更", "CommitTooltip": "提交暫存區變更", "AmendLastCommit": "修改上次提交", "AmendLastCommitTitle": "修改上次提交", "SureToAmend": "是否確定要修改上次提交?之後你可以從提交面板中再次更改此次提交的訊息。", "NoCommitToAmend": "沒有可以修改的提交。", "CommitChangesWithEditor": "使用 git 編輯器提交變更", "StatusTitle": "狀態", "GlobalTitle": "全域快捷鍵", "Menu": "選單", "Execute": "執行", "Stage": "切換預存", "ToggleStagedAll": "全部預存/取消預存", "ToggleTreeView": "顯示檔案樹狀視圖", "OpenDiffTool": "開啟外部差異工具 (git difftool)", "OpenMergeTool": "開啟外部合併工具", "OpenMergeToolTooltip": "執行 `git mergetool`。", "Refresh": "重新整理", "Push": "推送", "Pull": "拉取", "PushTooltip": "推送到遠端。如果沒有設定遠端,會開啟設定視窗。", "PullTooltip": "從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。", "Scroll": "捲動", "FileFilter": "篩選檔案 (預存/未預存)", "CopyToClipboardMenu": "複製到剪貼簿", "CopyFileName": "檔案名稱", "CopyFileDiffTooltip": "如果有已預存的項目,此指令只考慮它們。否則,它將考慮所有未暫存的項目。", "CopySelectedDiff": "所選檔案的差異", "CopyAllFilesDiff": "所有檔案的差異", "FileNameCopiedToast": "檔案名稱已複製", "FilePathCopiedToast": "檔案路徑已複製", "FileDiffCopiedToast": "已複製檔案差異", "AllFilesDiffCopiedToast": "已複製所有檔案差異", "FilterStagedFiles": "僅顯示預存的檔案", "FilterUnstagedFiles": "僅顯示未預存的檔案", "MergeConflictsTitle": "合併衝突", "Checkout": "檢出", "CheckoutTooltip": "檢出選定的項目。", "NoChangedFiles": "沒有變更的檔案", "SoftReset": "軟重設", "AlreadyCheckedOutBranch": "你已經檢出這個分支了", "SureForceCheckout": "是否強制檢出?這將會使你失去本地的所有更改", "ForceCheckoutBranch": "強制檢出分支", "BranchName": "分支名稱", "NewBranchNameBranchOff": "新的分支名稱 (根據 '{{.branchName}}' 分支創建)", "CantDeleteCheckOutBranch": "無法刪除已檢出的分支!", "DeleteRemoteBranchPrompt": "確定要刪除遠端 {{.upstream}} 的標籤 '{{.selectedBranchName}}'?", "ForceDeleteBranchMessage": "'{{.selectedBranchName}}' 分支尚未完全合併。是否刪除?", "RebaseBranch": "將已檢出的分支變基至此分支", "CantRebaseOntoSelf": "無法將分支變基至自己", "CantMergeBranchIntoItself": "無法將一個分支合併至自己", "ForceCheckout": "強制檢出", "CheckoutByName": "根據名稱檢出", "RemoteBranchCheckoutTitle": "檢出 {{.branchName}}", "CheckoutTypeNewBranch": "新本地分支", "CheckoutTypeNewBranchTooltip": "將遠端分支檢出為追蹤它的本地分支。", "CheckoutTypeDetachedHead": "分離 HEAD", "CheckoutTypeDetachedHeadTooltip": "將遠端分支檢出為分離的 HEAD,在只想測試但不動工時很實用。您稍後仍能根據它建立一個本地分支。", "NewBranch": "新分支", "NoBranchesThisRepo": "這個版本庫中沒有分支", "CommitWithoutMessageErr": "沒有提交訊息,無法提交", "Close": "關閉", "CloseCancel": "關閉/取消", "Confirm": "確認", "Quit": "結束", "CannotSquashOrFixupFirstCommit": "沒有可以壓縮的提交", "Fixup": "修復 (Fixup)", "SureFixupThisCommit": "是否對此提交進行 '修復' ? 其將被合併於以下之提交中", "SureSquashThisCommit": "是否要把這個提交壓縮到下面的提交中?", "Squash": "壓縮 (Squash)", "PickCommitTooltip": "挑選提交 (於變基過程中)", "Pick": "挑選", "Edit": "編輯", "RevertCommit": "還原提交", "Revert": "還原", "Reword": "改寫提交", "CommitRewordTooltip": "改寫選中的提交訊息", "DropCommit": "刪除提交", "MoveDownCommit": "向下移動提交", "MoveUpCommit": "向上移動提交", "EditCommit": "編輯(開始互動變基)", "EditCommitTooltip": "編輯提交", "AmendCommitTooltip": "使用已預存的更改修正提交", "Amend": "修改", "ResetAuthor": "重設作者", "SetAuthor": "設定作者", "AddCoAuthor": "添加合作者", "AmendCommitAttribute": "設定/重設提交作者", "SetAuthorPromptTitle": "設定作者(格式:「姓名 <電子郵件>」)", "SureResetCommitAuthor": "為了符合已配置的使用者,此作者的提交欄位以及時間戳將被更新。是否繼續?", "RewordCommitEditor": "使用編輯器改寫提交", "NoCommitsThisBranch": "這個分支沒有提交", "UpdateRefHere": "在這裡更新 '{{.ref}}' 分支", "Error": "錯誤", "Undo": "復原", "UndoReflog": "復原", "RedoReflog": "取消復原", "UndoTooltip": "將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。", "RedoTooltip": "將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。", "DiscardAllTooltip": "捨棄 '{{.path}}' 預存/未預存更改。", "DiscardUnstagedTooltip": "捨棄 '{{.path}}' 未預存更改。", "Pop": "還原", "Drop": "捨棄", "Apply": "套用", "NoStashEntries": "沒有收藏記錄", "StashDrop": "放棄收藏記錄", "StashPop": "還原收藏記錄", "SurePopStashEntry": "是否從收藏中還原這個記錄?", "StashApply": "套用收藏記錄", "SureApplyStashEntry": "是否套用這個收藏記錄?", "NoTrackedStagedFilesStash": "你沒有被追蹤的、預存的檔案可進行收藏", "NoFilesToStash": "沒有檔案可以進行收藏", "StashChanges": "安置現有變更到收藏中", "RenameStash": "重新命名收藏", "RenameStashPrompt": "重新命名收藏:{{.stashName}}", "OpenConfig": "開啟設定檔案", "EditConfig": "編輯設定檔案", "ForcePush": "強制推送", "ForcePushPrompt": "你的分支與遠端分支分岔。按 'ESC' 取消,或按 'Enter' 強制推送。", "ForcePushDisabled": "你的分支與遠端分支分岔,你已禁用強制推送", "UpdatesRejectedAndForcePushDisabled": "更新被拒絕,你已禁用強制推送", "CheckForUpdate": "檢查更新", "CheckingForUpdates": "正在檢查更新...", "UpdateAvailableTitle": "有可用的更新!", "UpdateAvailable": "下載並安裝版本 {{.newVersion}}?", "UpdateInProgressWaitingStatus": "更新中", "UpdateCompletedTitle": "更新已完成!", "UpdateCompleted": "更新已成功安裝。為了使其生效,請重新啟動 lazygit。", "FailedToRetrieveLatestVersionErr": "無法取得版本資訊", "OnLatestVersionErr": "已更新至最新版本", "MajorVersionErr": "新版本({{.newVersion}})不支援當前版本({{.currentVersion}})更改", "CouldNotFindBinaryErr": "找不到 {{.url}} 執行檔", "UpdateFailedErr": "更新失敗:{{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "正在更新中", "ConfirmQuitDuringUpdate": "正在進行更新,是否結束?", "MergeToolTitle": "合併工具", "MergeToolPrompt": "是否開啟 'git mergetool'?", "IntroPopupMessage": "\n感謝使用 lazygit!這裡有一些資源可供參考:\n\n 1) 📺lazygit 教學📺:\n https://youtu.be/CPLdltN7wgE\n\n 2) 📣發佈說明📣:\n https://github.com/jesseduffield/lazygit/releases\n\n 3) 💖如果你想要貢獻一份心力你可以💖:\n 改進 lazygit 原始碼:https://github.com/jesseduffield/lazygit\n 按右下角的捐款斗內我們\n 或單純添加 lazygit 到你的 star 清單內以增加曝光度都能大力的幫助我們!\n", "DeprecatedEditConfigWarning": "\n### Deprecated config warning ###\n\n以下設定已被取代並將於未來版本中刪除:\n{{configs}}\n\n編輯器設定教學:\n\n https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#configuring-file-editing\n\n", "GitconfigParseErr": "Gogit 無法解析你的 gitconfig 檔案,因為存在未引用的 '\\' 字符,刪除它們應該可以解決這個問題。", "EditFile": "編輯檔案", "EditFileTooltip": "使用外部編輯器開啟", "OpenFile": "開啟檔案", "OpenFileTooltip": "使用預設軟體開啟", "OpenInEditor": "在編輯器中開啟", "IgnoreFile": "添加到 .gitignore", "ExcludeFile": "添加到 .git/info/exclude", "RefreshFiles": "重新整理檔案", "Merge": "合併到當前檢出的分支", "RegularMerge": "一般合併", "ConfirmQuit": "是否結束?", "SwitchRepo": "切換到最近使用的版本庫", "UnsupportedGitService": "不支援的 git 服務", "CopyPullRequestURL": "複製拉取請求的 URL 到剪貼板", "NoBranchOnRemote": "這個分支在遠端不存在。需要先將其推送至遠端。", "Fetch": "擷取", "FetchTooltip": "同步遠端異動", "FileEnter": "選擇檔案中的單個程式碼塊/行,或展開/折疊目錄", "FileStagingRequirements": "只能選擇跟踪檔案中的單個行", "StageSelectionTooltip": "切換現有行的狀態 (已預存/未預存)", "DiscardSelection": "刪除變更 (git reset)", "ToggleSelectHunk": "切換選擇程式碼塊", "ToggleSelectionForPatch": "向 (或從) 補丁中添加/刪除行", "EditHunk": "編輯程式碼塊", "ToggleStagingView": "切換至另一個面板 (已預存/未預存更改)", "ReturnToFilesPanel": "返回檔案面板", "FastForward": "從上游快進此分支", "FastForwardTooltip": "從遠端快進所選的分支", "FastForwarding": "的擷取和快進中", "FoundConflictsTitle": "自動合併失敗", "ViewConflictsMenuItem": "檢視衝突", "AbortMenuItem": "中止%s", "PickHunk": "挑選程式碼片段", "PickAllHunks": "挑選所有程式碼片段", "ViewMergeRebaseOptions": "查看合併/變基選項", "ViewRebaseOptions": "查看合併/變基選項", "NotMergingOrRebasing": "你當前既不在變基也不在合併中", "AlreadyRebasing": "無法在變基期間執行此操作", "RecentRepos": "最近的版本庫", "MergeOptionsTitle": "合併選項", "RebaseOptionsTitle": "變基選項", "CommitSummaryTitle": "提交摘要", "CommitDescriptionTitle": "提交描述", "CommitDescriptionSubTitle": "按 tab 鍵聚焦", "LocalBranchesTitle": "本地分支", "SearchTitle": "搜尋", "TagsTitle": "標籤", "MenuTitle": "功能表", "RemotesTitle": "遠端", "RemoteBranchesTitle": "遠端分支", "PatchBuildingTitle": "主面板 (補丁生成)", "InformationTitle": "資訊", "SecondaryTitle": "次要", "ReflogCommitsTitle": "日誌", "ConflictsResolved": "所有合併衝突都已解決。是否繼續?", "Continue": "確認", "RebasingTitle": "將 '{{.checkedOutBranch}}'", "SimpleRebase": "簡單變基 變基至 '{{.ref}}'", "InteractiveRebase": "互動變基 變基至 '{{.ref}}'", "InteractiveRebaseTooltip": "開始一個互動變基,以中斷開始,這樣你可以在繼續之前更新TODO提交", "FwdNoUpstream": "無法快進無遠端的分支 ", "FwdNoLocalUpstream": "無法快進尚未在本地註冊的遠端分支", "FwdCommitsToPush": "無法快進帶有尚未推送的提交的分支", "PullRequestNoUpstream": "無法對沒有遠端的分支拉取", "ErrorOccurred": "發生錯誤!請在此詢問錯誤:", "NoRoom": "無足夠的空間", "YouAreHere": "你在這", "YouDied": "你死了!", "RewordNotSupported": "在互動變基期間改寫提交目前不支援", "ChangingThisActionIsNotAllowed": "不允許更改此類變基待辦事項", "CherryPickCopy": "複製提交 (揀選)", "PasteCommits": "貼上提交 (揀選)", "CherryPick": "揀選 (Cherry-pick)", "Donate": "贊助", "AskQuestion": "諮詢", "PrevLine": "選擇上一行", "NextLine": "選擇下一行", "PrevHunk": "選擇上一段", "NextHunk": "選擇下一段", "PrevConflict": "選擇上一個衝突", "NextConflict": "選擇下一個衝突", "SelectPrevHunk": "選擇上一段", "SelectNextHunk": "選擇下一段", "ScrollDown": "向下捲動", "ScrollUp": "向上捲動", "ScrollUpMainWindow": "向上捲動主面板", "ScrollDownMainWindow": "向下捲動主面板", "AmendCommitTitle": "修改提交", "AmendCommitPrompt": "是否使用預存檔案修改提交?", "DropCommitTitle": "刪除提交", "DropCommitPrompt": "是否刪除此提交?", "PullingStatus": "拉取", "PushingStatus": "推送", "FetchingStatus": "擷取", "SquashingStatus": "壓縮中", "FixingStatus": "修復中", "DeletingStatus": "刪除中", "MovingStatus": "移動中", "RebasingStatus": "變基中", "MergingStatus": "合併中", "LowercaseRebasingStatus": "變基", "LowercaseMergingStatus": "合併", "AmendingStatus": "修改中", "CherryPickingStatus": "揀選中", "UndoingStatus": "復原中", "RedoingStatus": "重做中", "CheckingOutStatus": "檢出中", "CommittingStatus": "提交中", "RevertingStatus": "還原中", "CommitFiles": "提交檔案", "SubCommitsDynamicTitle": "提交(%s)", "CommitFilesDynamicTitle": "差異檔案(%s)", "RemoteBranchesDynamicTitle": "遠端分支(%s)", "ViewItemFiles": "檢視所選項目的檔案", "CommitFilesTitle": "提交檔案", "CheckoutCommitFileTooltip": "檢出檔案", "DiscardFileChangesTitle": "捨棄檔案更改", "DiscardFileChangesPrompt": "是否捨棄此提交?如果這個檔案是在此提交中創建的,它將被刪除", "CreateRepo": "未在 git 版本庫中。是否建立新版本庫? (y/n): ", "BareRepo": "你嘗試在裸版本庫中開啟 Lazygit,但 Lazygit 尚未支援裸版本庫。是否開啟最新版本庫? (y/n) ", "InitialBranch": "分支名稱?(留空使用 git 的預設值):", "NoRecentRepositories": "必須在 git 版本庫中開啟 lazygit。沒有有效的最近版本庫。退出。", "IncorrectNotARepository": "無效 `notARepository` 輸入。輸入應為「prompt」、「create」、「skip」、或「quit」。", "AutoStashTitle": "是否自動收藏?", "AutoStashPrompt": "必須收藏並拾起變更才得以繼續操作。是否自動執行?(Enter/Esc)", "StashPrefix": "自動收藏 ", "Discard": "捨棄", "DiscardChangesTitle": "捨棄變更", "DiscardFileChangesTooltip": "檢視選中變動進行捨棄復原", "Cancel": "取消", "DiscardAllChanges": "刪除所有變更", "DiscardUnstagedChanges": "刪除未預存變更", "DiscardAllChangesToAllFiles": "刪除工作目錄", "DiscardAnyUnstagedChanges": "刪除未預存變更", "DiscardUntrackedFiles": "刪除未追蹤檔案", "DiscardStagedChanges": "刪除已預存變更", "HardReset": "強制重設", "Delete": "刪除", "Reset": "重設", "ViewResetOptions": "檢視重設選項", "CreateFixupCommit": "建立修復提交", "CreateFixupCommitTooltip": "為此提交建立修復提交", "SquashAboveCommitsTooltip": "是否壓縮上方 {{.commit}} 所有「fixup」提交?", "SquashAboveCommits": "壓縮上方所有「fixup」提交(自動壓縮)", "CommitChangesWithoutHook": "沒有預提交 hook 就提交更改", "ResetTo": "重設至", "PressEnterToReturn": "按 Enter 返回到 lazygit", "ViewStashOptions": "檢視收藏選項", "Stash": "收藏", "StashAllChanges": "收藏所有變更", "StashStagedChanges": "收藏已預存變更", "StashAllChangesKeepIndex": "收藏所有變更並保留預存區", "StashUnstagedChanges": "收藏未預存變更", "StashIncludeUntrackedChanges": "收藏所有變更,包括未追蹤檔案", "StashOptions": "收藏選項", "NotARepository": "錯誤:必須在 git 版本庫中執行", "Jump": "跳轉至面板", "ScrollLeftRight": "左右捲動", "ScrollLeft": "向左捲動", "ScrollRight": "向右捲動", "DiscardPatch": "捨棄補丁", "DiscardPatchConfirm": "你只能從單一提交或收藏項目建立一個補丁。是否捨棄當前補丁?", "CantPatchWhileRebasingError": "在合併或變基狀態下,你不能建立或運行補丁命令", "ToggleAddToPatch": "切換檔案是否包含在補丁中", "ToggleAllInPatch": "切換所有檔案是否包含在補丁中", "UpdatingPatch": "正在更新補丁", "ViewPatchOptions": "檢視自訂補丁選項", "PatchOptionsTitle": "補丁選項", "NoPatchError": "尚未建立補丁。要開始建立補丁,請在提交檔案上使用空格或輸入以添加特定行", "EnterCommitFile": "輸入檔案以將選定的行添加至補丁(或切換目錄折疊)", "ExitCustomPatchBuilder": "退出自訂補丁建立器", "EnterUpstream": "輸入遠端為 ' '", "InvalidUpstream": "無效的遠端分支名稱。必須符合 ' ' 的格式", "ReturnToRemotesList": "返回遠端列表", "NewRemote": "新增遠端", "NewRemoteName": "新遠端名稱:", "NewRemoteUrl": "新遠端 URL:", "EditRemoteName": "輸入更新 {{.remoteName}} 遠端名稱:", "EditRemoteUrl": "輸入更新 {{.remoteName}} 遠端 URL:", "RemoveRemote": "移除遠端", "DeleteRemoteBranch": "刪除遠端分支", "SetAsUpstream": "設置為遠端", "SetAsUpstreamTooltip": "將此分支設為當前分支之遠端", "SetUpstream": "設定選定分支的遠端分支", "UnsetUpstream": "重置選定分支的遠端", "ViewDivergenceFromUpstream": "檢視與遠端的差異", "DivergenceSectionHeaderLocal": "本地", "ViewUpstreamResetOptions": "重設當前分支進 {{.upstream}}", "ViewUpstreamResetOptionsTooltip": "查看重設當前分支進 {{upstream}} 的選項。注意:此動作不會重置所選的遠端,而是將當前分支重置到遠端", "ViewUpstreamRebaseOptions": "將當前分支變基到 {{.upstream}}", "ViewUpstreamRebaseOptionsTooltip": "查看變基當前分支到 {{upstream}} 的選項。注意:此動作不會變基所選的遠端,而是將當前分支變基到遠端", "UpstreamGenericName": "選定分支的選端", "SetUpstreamTitle": "設定遠端分支", "EditRemoteTooltip": "編輯遠端", "TagCommit": "打標籤到提交", "TagMenuTitle": "建立標籤", "TagNameTitle": "標籤名稱", "TagMessageTitle": "標籤訊息", "LightweightTag": "輕量標籤", "AnnotatedTag": "附註標籤", "DeleteLocalTag": "刪除本地標籤", "DeleteRemoteTagPrompt": "確定要刪除遠端 {{.upstream}} 的標籤 '{{.tagName}}'?", "PushTagTitle": "推送標籤 '{{.tagName}}' 至遠端:", "PushTag": "推送標籤", "NewTag": "建立標籤", "FetchRemoteTooltip": "擷取遠端", "FetchingRemoteStatus": "正在擷取遠端", "CheckoutCommit": "檢出提交", "SureCheckoutThisCommit": "你確定要檢出這個提交?", "GitFlowOptions": "顯示 git-flow 選項", "NotAGitFlowBranch": "這似乎不是一個 git flow 分支", "NewBranchNamePrompt": "為分支輸入新名稱", "IgnoreTracked": "忽略已追蹤檔案", "ExcludeTracked": "排除已追蹤檔案", "IgnoreTrackedPrompt": "你確定要忽略一個已追蹤的檔案?", "ViewResetToUpstreamOptions": "檢視遠端重設選項", "NextScreenMode": "下一個螢幕模式(常規/半螢幕/全螢幕)", "PrevScreenMode": "上一個螢幕模式", "StartSearch": "搜尋", "StartFilter": "搜尋", "Panel": "面板", "Keybindings": "鍵盤快捷鍵", "KeybindingsLegend": "說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B", "KeybindingsMenuSectionLocal": "本地", "KeybindingsMenuSectionGlobal": "全域", "RenameBranch": "重新命名分支", "Upstream": "遠端", "UpstreamTooltip": "查看所選分支的遠端設定(例如設置/重置遠端)", "BranchUpstreamOptionsTitle": "上游遠端設定", "ViewBranchUpstreamOptions": "檢視遠端設定", "ViewBranchUpstreamOptionsTooltip": "檢視有關遠端分支的設定(例如重設至遠端)", "UpstreamNotSetError": "目標分支沒有遠端對應分支(或其遠端分支未儲存於本地)", "NewGitFlowBranchPrompt": "{{.branchType}} 名稱:", "RenameBranchWarning": "此分支正在追蹤遠端分支。此操作僅會重新命名本地分支名稱,而不是遠端分支的名稱。是否繼續?", "OpenKeybindingsMenu": "開啟選單", "ResetCherryPick": "重設選定的揀選 (複製) 提交", "NextTab": "下一個索引標籤", "PrevTab": "上一個索引標籤", "CantUndoWhileRebasing": "在變基時無法復原", "CantRedoWhileRebasing": "在變基時無法取消復原", "MustStashWarning": "將補丁提取到索引中需要收藏並取消收藏你的變更。如果出現問題,你可以從收藏中訪問你的檔案。是否繼續?", "MustStashTitle": "必須收藏", "ConfirmationTitle": "確認面板", "PrevPage": "上一頁", "NextPage": "下一頁", "GotoTop": "捲動到頂部", "GotoBottom": "捲動到底部", "FilteringBy": "篩選方式", "ResetInParentheses": "(已重設)", "OpenFilteringMenu": "檢視篩選路徑選項", "FilterBy": "篩選路徑", "ExitFilterMode": "停止按路徑篩選", "FilterPathOption": "輸入要依路徑篩選的路徑", "EnterFileName": "輸入路徑:", "FilteringMenuTitle": "篩選", "MustExitFilterModeTitle": "命令不可用", "MustExitFilterModePrompt": "在按路徑篩選的模式下,該命令不可用。是否退出按路徑篩選的模式?", "Diff": "差異", "EnterRefToDiff": "輸入欲比較之 Ref", "EnterRefName": "輸入 Ref:", "ExitDiffMode": "退出差異模式", "DiffingMenuTitle": "差異比較", "SwapDiff": "反轉差異方向", "ViewDiffingOptions": "開啟差異比較選單", "OpenCommandLogMenu": "開啟命令記錄選單", "ShowingGitDiff": "顯示輸出:", "CommitDiff": "提交差異", "CopyCommitHashToClipboard": "複製提交 hash 到剪貼簿", "CommitHash": "提交 hash", "CommitURL": "提交 URL", "CopyCommitMessageToClipboard": "複製提交訊息到剪貼簿", "CommitMessage": "提交訊息", "CommitAuthor": "提交者", "CopyCommitAttributeToClipboard": "複製提交屬性", "CopyBranchNameToClipboard": "複製分支名稱到剪貼簿", "CopyPathToClipboard": "複製檔案名稱到剪貼簿", "CommitPrefixPatternError": "commitPrefix 模式錯誤", "CopySelectedTextToClipboard": "複製所選文本至剪貼簿", "NoFilesStagedTitle": "沒有檔案預存", "NoFilesStagedPrompt": "你沒有預存任何檔案。提交所有檔案?", "BranchNotFoundTitle": "找不到分支", "BranchNotFoundPrompt": "找不到分支。新分支名稱", "BranchUnknown": "分支未知", "DiscardChangeTitle": "取消預存行", "DiscardChangePrompt": "是否刪除所選行(git reset)?此操作不可逆。\n將「gui.skipDiscardChangeWarning」設為 true 可禁用此警告。", "CreateNewBranchFromCommit": "從提交建立新分支", "BuildingPatch": "正在建立補丁", "ViewCommits": "檢視提交", "RunningCustomCommandStatus": "正在執行自訂命令", "SubmoduleStashAndReset": "收藏未提交的子模組變更並更新", "AndResetSubmodules": "以及重設子模組", "EnterSubmoduleTooltip": "進入子模組", "CopySubmoduleNameToClipboard": "複製子模組名稱到剪貼簿", "RemoveSubmodule": "移除子模組", "RemoveSubmodulePrompt": "是否確定要刪除子模組 '%s' 以及它相應的目錄?此操作是不可逆的。", "ResettingSubmoduleStatus": "重設子模型中", "NewSubmoduleName": "子模組名稱:", "NewSubmoduleUrl": "新子模組 URL:", "NewSubmodulePath": "新子模組路徑:", "NewSubmodule": "新增子模組", "AddingSubmoduleStatus": "正在新增子模組", "UpdateSubmoduleUrl": "更新子模組 '%s' 的 URL", "UpdatingSubmoduleUrlStatus": "正在更新 URL", "EditSubmoduleUrl": "更新子模組 URL", "InitializingSubmoduleStatus": "正在初始化子模組", "InitSubmoduleTooltip": "初始化子模組", "SubmoduleUpdateTooltip": "更新子模組", "UpdatingSubmoduleStatus": "正在更新子模組", "BulkInitSubmodules": "批量初始化子模組", "BulkUpdateSubmodules": "批量更新子模組", "BulkDeinitSubmodules": "批量解除子模組初始化", "ViewBulkSubmoduleOptions": "查看批量子模組選項", "BulkSubmoduleOptions": "批量子模組選項", "RunningCommand": "正在執行命令", "SubCommitsTitle": "子提交", "SubmodulesTitle": "子模組", "NavigationTitle": "移動", "SuggestionsCheatsheetTitle": "提示", "SuggestionsTitle": "提示(按 %s 進入焦點)", "ExtrasTitle": "命令記錄", "PushingTagStatus": "正在推送標籤", "PullRequestURLCopiedToClipboard": "複製拉取請求 URL 至剪貼簿", "CommitDiffCopiedToClipboard": "已複製提交差異至剪貼簿", "CommitURLCopiedToClipboard": "已複製提交 URL 至剪貼簿", "CommitMessageCopiedToClipboard": "已複製提交訊息至剪貼簿", "CommitAuthorCopiedToClipboard": "已複製提交者至剪貼簿", "PatchCopiedToClipboard": "已複製補丁至剪貼簿", "CopiedToClipboard": "已複製至剪貼簿", "ErrCannotEditDirectory": "無法編輯目錄:你只能編輯單獨的檔案", "ErrStageDirWithInlineMergeConflicts": "不能預存/取消預存包含具備內嵌合併衝突的檔案的目錄。請先解決合併衝突", "ErrRepositoryMovedOrDeleted": "找不到版本庫。可能已被移動或刪除", "CommandLog": "命令記錄", "ToggleShowCommandLog": "切換顯示/隱藏命令記錄", "FocusCommandLog": "聚焦命令記錄", "CommandLogHeader": " '%s' 隱藏/聚焦此面板\n", "RandomTip": "隨機提示", "SelectParentCommitForMerge": "選擇合併的父提交", "ToggleWhitespaceInDiffView": "切換是否在差異檢視中顯示空格變更", "IgnoreWhitespaceDiffViewSubTitle": "(忽略空格)", "IgnoreWhitespaceNotSupportedHere": "在此檢視中不支援忽略空格", "IncreaseContextInDiffView": "增加差異檢視中顯示變更周圍上下文的大小", "DecreaseContextInDiffView": "減小差異檢視中顯示變更周圍上下文的大小", "CreatePullRequestOptions": "建立拉取請求選項", "DefaultBranch": "預設分支", "SelectBranch": "選擇分支", "CreatePullRequest": "建立拉取請求", "SelectConfigFile": "選擇設定檔", "NoConfigFileFoundErr": "找不到設定檔", "LoadingFileSuggestions": "正在加載檔案建議", "LoadingCommits": "正在加載提交", "MustSpecifyOriginError": "如果指定分支,必須指定遠端", "GitOutput": "git 輸出:", "GitCommandFailed": "git 命令失敗。請查看命令記錄以獲取詳細資訊(按 %s 開啟)", "AbortTitle": "中止%s", "AbortPrompt": "是否確定要中止當前的%s?", "OpenLogMenu": "開啟記錄選單", "LogMenuTitle": "提交記錄選項", "ToggleShowGitGraphAll": "切換顯示整個 git 圖表(將 `--all` 標誌傳遞給 `git log`)", "ShowGitGraph": "顯示 git 圖表", "SortOrder": "排序規則", "SortAlphabetical": "依字母", "SortByDate": "依時間", "SortByRecency": "依最近使用", "SortBasedOnReflog": "(依據歷史記錄)", "SortCommits": "提交排序順序", "CantChangeContextSizeError": "在製作補丁期間無法更改上下文大小,因為當發布功能時我們太懒了以至於沒有支援它。如果你真的需要它,請告訴我們!", "OpenCommitInBrowser": "在瀏覽器中開啟提交", "ViewBisectOptions": "查看二分選項", "ConfirmRevertCommit": "是否還原 {{.selectedCommit}} ?", "RewordInEditorTitle": "在編輯器中改寫", "RewordInEditorPrompt": "是否在編輯器中改寫此提交?", "HardResetAutostashPrompt": "是否強制重設為 '%s' ?如果需要會進行自動存儲。", "UpstreamGone": "(遠端已經不存在)", "NukeDescription": "如果你想讓所有工作樹上的變更消失,這就是正確的選項。如果有未提交的子模組變更,它們將被收藏在子模組中。", "DiscardStagedChangesDescription": "這將創建一個新的存儲條目,其中只包含預存檔案,然後如果存儲條目不需要,將其刪除,因此工作樹僅保留未預存的變更。", "EmptyOutput": "<空輸出>", "Patch": "補丁", "CustomPatch": "自定義補丁", "CommitsCopied": "提交已複製", "CommitCopied": "提交已複製", "ResetPatch": "重設補丁", "ApplyPatch": "套用補丁", "ApplyPatchInReverse": "反向套用補丁", "RemovePatchFromOriginalCommit": "從原始提交中刪除補丁(%s)", "MovePatchOutIntoIndex": "將補丁移到預存區", "MovePatchIntoNewCommit": "將補丁移到新的提交", "MovePatchToSelectedCommit": "將補丁移到選定的提交(%s)", "CopyPatchToClipboard": "將補丁複製到剪貼簿", "NoMatchesFor": "沒有找到符合 '%s' %s 的結果", "MatchesFor": "符合 '%s' 的結果(%d/%d)%s", "SearchKeybindings": "%s:下一個結果,%s:上一個結果,%s:退出搜尋模式", "SearchPrefix": "搜尋:", "FilterPrefix": "篩選:", "ExitSearchMode": "%s:退出搜尋模式", "SwitchToWorktree": "切換至工作目錄面板", "AlreadyCheckedOutByWorktree": "此分支已被檢出到 {{.worktreeName}} 是否切換到此工作目錄?", "BranchCheckedOutByWorktree": "分支 {{.branchName}} 已被 {{.worktreeName}} 檢出", "DetachWorktreeTooltip": "此將在工作目錄中執行 `git checkout --detach` 以解開分支與它的連結,但工作目錄本身將不被更動", "Switching": "切換中", "RemoveWorktree": "刪除工作目錄", "RemoveWorktreeTitle": "刪除工作目錄", "DetachWorktree": "解開工作目錄連結", "DetachingWorktree": "正在解除工作目錄連結", "WorktreesTitle": "工作目錄", "WorktreeTitle": "工作目錄", "RemoveWorktreePrompt": "是否刪除 {{.worktreeName}} 工作目錄?", "ForceRemoveWorktreePrompt": "'{{.worktreeName}}' 包括已更動或未追蹤的檔案。是否繼續刪除工作目錄?", "RemovingWorktree": "正在刪除工作目錄", "AddingWorktree": "正在建立工作目錄", "CantDeleteCurrentWorktree": "無法刪除當前工作目錄!", "AlreadyInWorktree": "已經在目標工作目錄內", "CantDeleteMainWorktree": "無法刪除主要工作目錄!", "NoWorktreesThisRepo": "無工作目錄", "MissingWorktree": "(失蹤)", "MainWorktree": "(主要)", "NewWorktreePath": "工作目錄路徑", "NewWorktreeBase": "工作目錄來源", "BranchNameCannotBeBlank": "分支名稱不能為空", "NewBranchName": "分支名稱", "NewBranchNameLeaveBlank": "分支名稱(留空將檢出 {{.default}})", "ViewWorktreeOptions": "檢視工作目錄選項", "CreateWorktreeFrom": "從 {{.ref}} 建立工作目錄", "CreateWorktreeFromDetached": "從 {{.ref}} 建立工作目錄(未連結)", "LcWorktree": "工作目錄", "ChangingDirectoryTo": "切換至 {{.path}}", "Name": "名稱", "Branch": "分支", "Path": "路徑", "MarkedBaseCommitStatus": "為了變基已標注基準提交", "MarkAsBaseCommit": "為了變基已標注提交為基準提交", "MarkAsBaseCommitTooltip": "請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。", "MarkedCommitMarker": "↑↑↑ 將由此變基 ↑↑↑", "NoCopiedCommits": "未複製提交", "DisabledMenuItemPrefix": "已停用:", "QuickStartInteractiveRebase": "開始互動變基", "ToggleRangeSelect": "切換拖曳選擇", "Actions": { "CheckoutCommit": "檢出提交", "CheckoutTag": "檢出標籤", "CheckoutBranch": "檢出分支", "ForceCheckoutBranch": "強制檢出分支", "DeleteLocalBranch": "刪除本地分支", "Merge": "合併", "RebaseBranch": "變基分支", "RenameBranch": "重新命名分支", "CreateBranch": "建立分支", "FastForwardBranch": "快進分支", "CherryPick": "(Cherry-pick)複製提交", "CheckoutFile": "檢出檔案", "DiscardOldFileChange": "放棄舊檔案更改", "SquashCommitDown": "下列次方執行 Squash", "FixupCommit": "修復提交", "RewordCommit": "改寫提交", "DropCommit": "捨棄提交", "EditCommit": "編輯提交", "AmendCommit": "修改提交", "ResetCommitAuthor": "重設提交作者", "SetCommitAuthor": "設置提交作者", "RevertCommit": "還原提交", "CreateFixupCommit": "建立修改提交", "SquashAllAboveFixupCommits": "Squash 所有上面的修改提交", "MoveCommitUp": "上移提交", "MoveCommitDown": "下移提交", "CopyCommitMessageToClipboard": "將提交訊息複製到剪貼簿", "CopyCommitDiffToClipboard": "將提交差異複製到剪貼簿", "CopyCommitHashToClipboard": "將提交 hash 複製到剪貼簿", "CopyCommitURLToClipboard": "將提交 URL 複製到剪貼簿", "CopyCommitAuthorToClipboard": "將提交作者複製到剪貼簿", "CopyCommitAttributeToClipboard": "複製到剪貼簿", "CopyPatchToClipboard": "將補丁複製到剪貼簿", "CustomCommand": "自定義命令", "DiscardAllChangesInDirectory": "捨棄目錄中的所有更改", "DiscardUnstagedChangesInDirectory": "捨棄目錄中未預存的更改", "DiscardAllChangesInFile": "捨棄檔案中的所有更改", "DiscardAllUnstagedChangesInFile": "捨棄檔案中未預存的所有更改", "StageFile": "預存檔案", "StageResolvedFiles": "預存已解決合併衝突的檔案", "UnstageFile": "取消預存檔案", "UnstageAllFiles": "取消預存所有檔案", "StageAllFiles": "預存所有檔案", "IgnoreExcludeFile": "忽略或排除檔案", "IgnoreFileErr": "無法忽略 .gitignore 檔案", "ExcludeFile": "排除檔案", "ExcludeGitIgnoreErr": "無法排除 .gitignore 檔案", "Commit": "提交", "EditFile": "編輯檔案", "Push": "推送", "Pull": "拉取", "OpenFile": "開啟檔案", "StashAllChanges": "收藏所有更改", "StashAllChangesKeepIndex": "收藏所有更改並保留索引", "StashStagedChanges": "收藏已預存的更改", "StashUnstagedChanges": "收藏未預存的更改", "StashIncludeUntrackedChanges": "收藏所有更改,包括未追蹤的檔案", "GitFlowFinish": "`git flow` 完成", "GitFlowStart": "`git flow` 開始", "CopyToClipboard": "複製到剪貼簿", "CopySelectedTextToClipboard": "複製所選文本到剪貼簿", "RemovePatchFromCommit": "從提交中刪除補丁", "MovePatchToSelectedCommit": "將補丁移動到所選提交", "MovePatchIntoIndex": "將補丁移動到索引中", "MovePatchIntoNewCommit": "將補丁移動到新提交中", "DeleteRemoteBranch": "刪除遠端分支", "SetBranchUpstream": "設置遠端分支", "AddRemote": "添加遠端", "RemoveRemote": "移除遠端", "UpdateRemote": "更新遠端", "ApplyPatch": "套用補丁", "Stash": "收藏 (Stash)", "RenameStash": "重命名暫存", "RemoveSubmodule": "移除子模塊", "ResetSubmodule": "重設子模塊", "AddSubmodule": "添加子模塊", "UpdateSubmoduleUrl": "更新子模塊 URL", "InitialiseSubmodule": "初始化子模塊", "BulkInitialiseSubmodules": "批量初始化子模塊", "BulkUpdateSubmodules": "批量更新子模塊", "BulkDeinitialiseSubmodules": "批量取消初始化子模塊", "UpdateSubmodule": "更新子模塊", "CreateLightweightTag": "建立輕量標籤", "CreateAnnotatedTag": "建立附註標籤", "DeleteLocalTag": "刪除本地標籤", "PushTag": "推送標籤", "NukeWorkingTree": "清空工作樹", "DiscardUnstagedFileChanges": "放棄未預存的檔案更改", "RemoveUntrackedFiles": "移除未追蹤的檔案", "RemoveStagedFiles": "移除已預存的檔案", "SoftReset": "軟重設(保留變動)", "MixedReset": "混合重設", "HardReset": "強制重設", "Undo": "復原", "Redo": "重做", "CopyPullRequestURL": "複製拉取請求的 URL", "OpenMergeTool": "開啟合併工具", "OpenCommitInBrowser": "在瀏覽器中開啟提交", "OpenPullRequest": "在瀏覽器中開啟拉取請求", "StartBisect": "開始二分查找", "ResetBisect": "重設二分查找", "BisectSkip": "二分查找跳過", "BisectMark": "二分查找標記" }, "Bisect": { "MarkStart": "將 %s 標記為 %s(開始二分查找)", "ResetTitle": "重設 `git bisect`", "ResetPrompt": "是否重設 `git bisect`?", "ResetOption": "重設二分查找", "BisectMenuTitle": "二分查找", "Mark": "將 %s 標記為 %s", "SkipCurrent": "跳過 %s", "CompleteTitle": "二分查找完成", "CompletePrompt": "二分查找完成!以下提交引入了更改:\n\n%s\n\n是否重設 `git bisect` ?", "CompletePromptIndeterminate": "二分查找完成!有一些提交被跳過,因此以下任何提交皆可能引進更改:\n\n%s\n\n是否重設 `git bisect`?", "Bisecting": "二分查找中" }, "Log": { "CopyToClipboard": "{{.str}} 已複製" }, "BreakingChangesByVersion": {} } lazygit-0.50.0+ds1/pkg/integration/000077500000000000000000000000001500612110400170345ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/README.md000066400000000000000000000157141500612110400203230ustar00rootroot00000000000000# Integration Tests The pkg/integration package is for integration testing: that is, actually running a real lazygit session and having a robot pretend to be a human user and then making assertions that everything works as expected. TL;DR: integration tests live in pkg/integration/tests. Run integration tests with: ```sh go run cmd/integration_test/main.go tui ``` or ```sh go run cmd/integration_test/main.go cli [--slow or --sandbox] [testname or testpath...] ``` ## Writing tests The tests live in pkg/integration/tests. Each test is registered in `pkg/integration/tests/test_list.go` which is an auto-generated file. You can re-generate that file by running `go generate ./...` at the root of the Lazygit repo. Each test has two important steps: the setup step and the run step. ### Setup step In the setup step, we prepare a repo with shell commands, for example, creating a merge conflict that will need to be resolved upon opening lazygit. This is all done via the `shell` argument. When the test runs, lazygit will open in the same working directory that the shell ends up in (so if you want to start lazygit somewhere other than the default location, you can use `shell.Chdir()` at the end of the setup step to set that working directory. ### Run step The run step has two arguments passed in: 1. `t` (the test driver) 2. `keys` `t` is for driving the gui by pressing certain keys, selecting list items, etc. `keys` is for use when getting the test to press a particular key e.g. `t.Views().Commits().Focus().PressKey(keys.Universal.Confirm)` ## Running tests There are three ways to invoke a test: 1. go run cmd/integration_test/main.go cli [--slow or --sandbox] [testname or testpath...] 2. go run cmd/integration_test/main.go tui 3. go test pkg/integration/clients/*.go The first, the test runner, is for directly running a test from the command line. If you pass no arguments, it runs all tests. The second, the TUI, is for running tests from a terminal UI where it's easier to find a test and run it without having to copy it's name and paste it into the terminal. This is the easiest approach by far. The third, the go-test command, intended only for use in CI, to be run along with the other `go test` tests. This runs the tests in headless mode so there's no visual output. The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is commit/new_branch. So to run it with our test runner you would run `go run cmd/integration_test/main.go cli commit/new_branch`. You can pass the INPUT_DELAY env var to the test runner in order to set a delay in milliseconds between keypresses or mouse clicks, which helps for watching a test at a realistic speed to understand what it's doing. Or you can pass the '--slow' flag which sets a pre-set 'slow' key delay. In the tui you can press 't' to run the test in slow mode. The resultant repo will be stored in `test/_results`, so if you're not sure what went wrong you can go there and inspect the repo. ### Running tests in VSCode If you've opened an integration test file in your editor you can run that file by bringing up the command panel with `cmd+shift+p` and typing 'run task', then selecting the test task you want to run ![image](https://user-images.githubusercontent.com/8456633/201500427-b86e129f-5f35-4d55-b7bd-fff5d8e4a04e.png) ![image](https://user-images.githubusercontent.com/8456633/201500431-903deb8c-c210-4054-8514-ab7088c7a839.png) The test will run in a VSCode terminal: ![image](https://user-images.githubusercontent.com/8456633/201500446-b87abf11-9653-438f-8a9a-e0bf8abdb7ee.png) ### Debugging tests Debugging an integration test is possible in two ways: 1. Use the -debug option of the integration test runner's "cli" command, e.g. `go run cmd/integration_test/main.go cli -debug tag/reset.go` 2. Select a test in the "tui" runner and hit "d" to debug it. In both cases the test runner will print to the console that it is waiting for a debugger to attach, so now you need to tell your debugger to attach to a running process with the name "test_lazygit". If you are using Visual Studio Code, an easy way to do that is to use the "Attach to integration test runner" debug configuration. The test runner will resume automatically when it detects that a debugger was attached. Don't forget to set a breakpoint in the code that you want to step through, otherwise the test will just finish (i.e. it doesn't stop in the debugger automatically). ### Sandbox mode Say you want to do a manual test of how lazygit handles merge-conflicts, but you can't be bothered actually finding a way to create merge conflicts in a repo. To make your life easier, you can simply run a merge-conflicts test in sandbox mode, meaning the setup step is run for you, and then instead of the test driving the lazygit session, you're allowed to drive it yourself. To run a test in sandbox mode you can press 's' on a test in the test TUI or in the test runner pass the --sandbox argument. ## Tips for writing tests ### Handle most setup in the `shell` part of the test Try to do as much setup work as possible in your setup step. For example, if all you're testing is that the user is able to resolve merge conflicts, create the merge conflicts in the setup step. On the other hand, if you're testing to see that lazygit can warn the user about merge conflicts after an attempted merge, it's fine to wait until the run step to actually create the conflicts. If the run step is focused on the thing you're trying to test, the test will run faster and its intent will be clearer. ### Create helper functions for (very) frequently used test logic If within a test directory you find several tests need to share some logic, you can create a file called `shared.go` in that directory to hold shared helper functions (see `pkg/integration/tests/filter_by_path/shared.go` for an example). If you need to share test logic across test directories you can put helper functions in the `tests/shared` package. If you find yourself frequently doing the same thing from within a test across test directories, for example, responding a particular popup, consider adding a helper method to `pkg/integration/components/common.go`. If you look around the code in the `components` directory you may find another place that's sensible to put your helper function. ### Don't do too much in one test If you're testing different pieces of functionality, it's better to test them in isolation using multiple short tests, compared to one larger longer test. Sometimes it's appropriate to have a longer test which tests how various different pieces interact, but err on the side of keeping things short. ## Testing against old git versions Our CI tests against multiple git versions. If your test fails on an old version, then to troubleshoot you'll need to install the failing git version. One option is to use [rtx](https://github.com/jdxcode/rtx) (see installation steps in the readme) with the git plugin like so: ```sh rtx plugin add git rtx install git 2.20.0 rtx local git 2.20.0 ``` lazygit-0.50.0+ds1/pkg/integration/clients/000077500000000000000000000000001500612110400204755ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/clients/cli.go000066400000000000000000000062301500612110400215740ustar00rootroot00000000000000package clients import ( "log" "os" "os/exec" "regexp" "strconv" "strings" "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests" "github.com/samber/lo" ) // see pkg/integration/README.md // The purpose of this program is to run integration tests. It does this by // building our injector program (in the sibling injector directory) and then for // each test we're running, invoke the injector program with the test's name as // an environment variable. Then the injector finds the test and passes it to // the lazygit startup code. // If invoked directly, you can specify tests to run by passing their names as positional arguments func RunCLI(testNames []string, slow bool, sandbox bool, waitForDebugger bool, raceDetector bool) { inputDelay := tryConvert(os.Getenv("INPUT_DELAY"), 0) if slow { inputDelay = SLOW_INPUT_DELAY } err := components.RunTests(components.RunTestArgs{ Tests: getTestsToRun(testNames), Logf: log.Printf, RunCmd: runCmdInTerminal, TestWrapper: runAndPrintFatalError, Sandbox: sandbox, WaitForDebugger: waitForDebugger, RaceDetector: raceDetector, CodeCoverageDir: "", InputDelay: inputDelay, MaxAttempts: 1, }) if err != nil { log.Print(err.Error()) } } func runAndPrintFatalError(test *components.IntegrationTest, f func() error) { if err := f(); err != nil { log.Fatal(err.Error()) } } func getTestsToRun(testNames []string) []*components.IntegrationTest { allIntegrationTests := tests.GetTests(utils.GetLazyRootDirectory()) var testsToRun []*components.IntegrationTest if len(testNames) == 0 { return allIntegrationTests } testNames = lo.Map(testNames, func(name string, _ int) string { // allowing full test paths to be passed for convenience return strings.TrimSuffix( regexp.MustCompile(`.*pkg/integration/tests/`).ReplaceAllString(name, ""), ".go", ) }) if lo.SomeBy(testNames, func(name string) bool { return strings.HasSuffix(name, "/shared") }) { log.Fatalf("'shared' is a reserved name for tests that are shared between multiple test files. Please rename your test.") } outer: for _, testName := range testNames { // check if our given test name actually exists for _, test := range allIntegrationTests { if test.Name() == testName { testsToRun = append(testsToRun, test) continue outer } } log.Fatalf("test %s not found. Perhaps you forgot to add it to `pkg/integration/integration_tests/test_list.go`? This can be done by running `go generate ./...` from the Lazygit root. You'll need to ensure that your test name and the file name match (where the test name is in PascalCase and the file name is in snake_case).", testName) } return testsToRun } func runCmdInTerminal(cmd *exec.Cmd) (int, error) { cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return -1, err } return cmd.Process.Pid, cmd.Wait() } func tryConvert(numStr string, defaultVal int) int { num, err := strconv.Atoi(numStr) if err != nil { return defaultVal } return num } lazygit-0.50.0+ds1/pkg/integration/clients/go_test.go000066400000000000000000000050401500612110400224670ustar00rootroot00000000000000//go:build !windows // +build !windows package clients // This file allows you to use `go test` to run integration tests. // See pkg/integration/README.md for more info. import ( "bytes" "errors" "io" "os" "os/exec" "testing" "github.com/creack/pty" "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests" "github.com/stretchr/testify/assert" ) func TestIntegration(t *testing.T) { if testing.Short() { t.Skip("Skipping integration tests in short mode") } parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1) parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0) raceDetector := os.Getenv("LAZYGIT_RACE_DETECTOR") != "" // LAZYGIT_GOCOVERDIR is the directory where we write coverage files to. If this directory // is defined, go binaries built with the -cover flag will write coverage files to // to it. codeCoverageDir := os.Getenv("LAZYGIT_GOCOVERDIR") testNumber := 0 err := components.RunTests(components.RunTestArgs{ Tests: tests.GetTests(utils.GetLazyRootDirectory()), Logf: t.Logf, RunCmd: runCmdHeadless, TestWrapper: func(test *components.IntegrationTest, f func() error) { defer func() { testNumber += 1 }() if testNumber%parallelTotal != parallelIndex { return } t.Run(test.Name(), func(t *testing.T) { t.Parallel() err := f() assert.NoError(t, err) }) }, Sandbox: false, WaitForDebugger: false, RaceDetector: raceDetector, CodeCoverageDir: codeCoverageDir, InputDelay: 0, // Allow two attempts at each test to get around flakiness MaxAttempts: 2, }) assert.NoError(t, err) } func runCmdHeadless(cmd *exec.Cmd) (int, error) { cmd.Env = append( cmd.Env, "HEADLESS=true", "TERM=xterm", ) // not writing stderr to the pty because we want to capture a panic if // there is one. But some commands will not be in tty mode if stderr is // not a terminal. We'll need to keep an eye out for that. stderr := new(bytes.Buffer) cmd.Stderr = stderr // these rows and columns are ignored because internally we use tcell's // simulation screen. However we still need the pty for the sake of // running other commands in a pty. f, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 300, Cols: 300}) if err != nil { return -1, err } _, _ = io.Copy(io.Discard, f) if cmd.Wait() != nil { _ = f.Close() // return an error with the stderr output return cmd.Process.Pid, errors.New(stderr.String()) } return cmd.Process.Pid, f.Close() } lazygit-0.50.0+ds1/pkg/integration/clients/injector/000077500000000000000000000000001500612110400223125ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/clients/injector/main.go000066400000000000000000000053711500612110400235730ustar00rootroot00000000000000package main import ( "fmt" "os" "time" "github.com/jesseduffield/lazygit/pkg/app" "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" "github.com/mitchellh/go-ps" ) // The purpose of this program is to run lazygit with an integration test passed in. // We could have done the check on TEST_NAME in the root main.go but // that would mean lazygit would be depending on integration test code which // would bloat the binary. // You should not invoke this program directly. Instead you should go through // go run cmd/integration_test/main.go func main() { dummyBuildInfo := &app.BuildInfo{ Commit: "", Date: "", Version: "", BuildSource: "integration test", } integrationTest := getIntegrationTest() if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) != "" && !daemon.InDaemonMode() { println("Waiting for debugger to attach...") for !isDebuggerAttached() { time.Sleep(time.Millisecond * 100) } println("Debugger attached, continuing") } app.Start(dummyBuildInfo, integrationTest) } func getIntegrationTest() integrationTypes.IntegrationTest { if daemon.InDaemonMode() { // if we've invoked lazygit as a daemon from within lazygit, // we don't want to pass a test to the rest of the code. return nil } integrationTestName := os.Getenv(components.TEST_NAME_ENV_VAR) if integrationTestName == "" { panic(fmt.Sprintf( "expected %s environment variable to be set, given that we're running an integration test", components.TEST_NAME_ENV_VAR, )) } lazygitRootDir := os.Getenv(components.LAZYGIT_ROOT_DIR) allTests := tests.GetTests(lazygitRootDir) for _, candidateTest := range allTests { if candidateTest.Name() == integrationTestName { return candidateTest } } panic("Could not find integration test with name: " + integrationTestName) } // Returns whether we are running under a debugger. It uses a heuristic to find // out: when using dlv, it starts a debugserver executable (which is part of // lldb), and the debuggee becomes a child process of that. So if the name of // our parent process is "debugserver", we run under a debugger. This works even // if the parent process used to be the shell and you then attach to the running // executable. // // On Mac this works with VS Code, with the Jetbrains Goland IDE, and when using // dlv attach in a terminal. I have not been able to verify that it works on // other platforms, it may have to be adapted there. func isDebuggerAttached() bool { process, err := ps.FindProcess(os.Getppid()) if err != nil { return false } return process.Executable() == "debugserver" } lazygit-0.50.0+ds1/pkg/integration/clients/tui.go000066400000000000000000000233601500612110400216310ustar00rootroot00000000000000package clients import ( "fmt" "log" "os" "os/exec" "path/filepath" "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/gui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests" "github.com/samber/lo" ) // This program lets you run integration tests from a TUI. See pkg/integration/README.md for more info. var SLOW_INPUT_DELAY = 600 func RunTUI(raceDetector bool) { rootDir := utils.GetLazyRootDirectory() testDir := filepath.Join(rootDir, "test", "integration") app := newApp(testDir) app.loadTests() g, err := gocui.NewGui(gocui.NewGuiOpts{ OutputMode: gocui.OutputTrue, RuneReplacements: gui.RuneReplacements, }) if err != nil { log.Panicln(err) } g.Cursor = false app.g = g g.SetManagerFunc(app.layout) if err := g.SetKeybinding("list", gocui.KeyArrowUp, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { if app.itemIdx > 0 { app.itemIdx-- } listView, err := g.View("list") if err != nil { return err } listView.FocusPoint(0, app.itemIdx) return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { if app.itemIdx < len(app.filteredTests)-1 { app.itemIdx++ } listView, err := g.View("list") if err != nil { return err } listView.FocusPoint(0, app.itemIdx) return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", 'q', gocui.ModNone, quit); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", 's', gocui.ModNone, func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil } suspendAndRunTest(currentTest, true, false, raceDetector, 0) return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", gocui.KeyEnter, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil } suspendAndRunTest(currentTest, false, false, raceDetector, 0) return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", 't', gocui.ModNone, func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil } suspendAndRunTest(currentTest, false, false, raceDetector, SLOW_INPUT_DELAY) return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", 'd', gocui.ModNone, func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil } suspendAndRunTest(currentTest, false, true, raceDetector, 0) return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", 'o', gocui.ModNone, func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil } cmd := exec.Command("sh", "-c", fmt.Sprintf("code -r pkg/integration/tests/%s.go", currentTest.Name())) if err := cmd.Run(); err != nil { return err } return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", 'O', gocui.ModNone, func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil } cmd := exec.Command("sh", "-c", fmt.Sprintf("code test/_results/%s", currentTest.Name())) if err := cmd.Run(); err != nil { return err } return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("list", '/', gocui.ModNone, func(*gocui.Gui, *gocui.View) error { app.filtering = true if _, err := g.SetCurrentView("editor"); err != nil { return err } editorView, err := g.View("editor") if err != nil { return err } editorView.Clear() return nil }); err != nil { log.Panicln(err) } // not using the editor yet, but will use it to help filter the list if err := g.SetKeybinding("editor", gocui.KeyEsc, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { app.filtering = false if _, err := g.SetCurrentView("list"); err != nil { return err } app.filteredTests = app.allTests app.renderTests() app.editorView.TextArea.Clear() app.editorView.Clear() app.editorView.Reset() return nil }); err != nil { log.Panicln(err) } if err := g.SetKeybinding("editor", gocui.KeyEnter, gocui.ModNone, func(*gocui.Gui, *gocui.View) error { app.filtering = false if _, err := g.SetCurrentView("list"); err != nil { return err } app.renderTests() return nil }); err != nil { log.Panicln(err) } err = g.MainLoop() g.Close() switch err { case gocui.ErrQuit: return default: log.Panicln(err) } } type app struct { allTests []*components.IntegrationTest filteredTests []*components.IntegrationTest itemIdx int testDir string filtering bool g *gocui.Gui listView *gocui.View editorView *gocui.View } func newApp(testDir string) *app { return &app{testDir: testDir, allTests: tests.GetTests(utils.GetLazyRootDirectory())} } func (self *app) getCurrentTest() *components.IntegrationTest { self.adjustCursor() if len(self.filteredTests) > 0 { return self.filteredTests[self.itemIdx] } return nil } func (self *app) loadTests() { self.filteredTests = self.allTests self.adjustCursor() } func (self *app) adjustCursor() { self.itemIdx = utils.Clamp(self.itemIdx, 0, len(self.filteredTests)-1) } func (self *app) filterWithString(needle string) { if needle == "" { self.filteredTests = self.allTests } else { self.filteredTests = lo.Filter(self.allTests, func(test *components.IntegrationTest, _ int) bool { return strings.Contains(test.Name(), needle) }) } self.renderTests() self.g.Update(func(g *gocui.Gui) error { return nil }) } func (self *app) renderTests() { self.listView.Clear() for _, test := range self.filteredTests { fmt.Fprintln(self.listView, test.Name()) } } func (self *app) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { matched := f(v, key, ch, mod) if matched { self.filterWithString(v.TextArea.GetContent()) } return matched } } func suspendAndRunTest(test *components.IntegrationTest, sandbox bool, waitForDebugger bool, raceDetector bool, inputDelay int) { if err := gocui.Screen.Suspend(); err != nil { panic(err) } runTuiTest(test, sandbox, waitForDebugger, raceDetector, inputDelay) fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint("press enter to return")) _, _ = fmt.Scanln() // wait for enter press if err := gocui.Screen.Resume(); err != nil { panic(err) } } func (self *app) layout(g *gocui.Gui) error { maxX, maxY := g.Size() descriptionViewHeight := 7 keybindingsViewHeight := 3 editorViewHeight := 3 if !self.filtering { editorViewHeight = 0 } else { descriptionViewHeight = 0 keybindingsViewHeight = 0 } g.Cursor = self.filtering g.FgColor = gocui.ColorGreen listView, err := g.SetView("list", 0, 0, maxX-1, maxY-descriptionViewHeight-keybindingsViewHeight-editorViewHeight-1, 0) if err != nil { if !gocui.IsUnknownView(err) { return err } if self.listView == nil { self.listView = listView } listView.Highlight = true listView.SelBgColor = gocui.ColorBlue self.renderTests() listView.Title = "Tests" listView.FgColor = gocui.ColorDefault if _, err := g.SetCurrentView("list"); err != nil { return err } } descriptionView, err := g.SetViewBeneath("description", "list", descriptionViewHeight) if err != nil { if !gocui.IsUnknownView(err) { return err } descriptionView.Title = "Test description" descriptionView.Wrap = true descriptionView.FgColor = gocui.ColorDefault } keybindingsView, err := g.SetViewBeneath("keybindings", "description", keybindingsViewHeight) if err != nil { if !gocui.IsUnknownView(err) { return err } keybindingsView.Title = "Keybindings" keybindingsView.Wrap = true keybindingsView.FgColor = gocui.ColorDefault fmt.Fprintln(keybindingsView, "up/down: navigate, enter: run test, t: run test slow, s: sandbox, d: debug test, o: open test file, shift+o: open test snapshot directory, forward-slash: filter") } editorView, err := g.SetViewBeneath("editor", "keybindings", editorViewHeight) if err != nil { if !gocui.IsUnknownView(err) { return err } if self.editorView == nil { self.editorView = editorView } editorView.Title = "Filter" editorView.FgColor = gocui.ColorDefault editorView.Editable = true editorView.Editor = gocui.EditorFunc(self.wrapEditor(gocui.SimpleEditor)) } currentTest := self.getCurrentTest() if currentTest == nil { return nil } descriptionView.Clear() fmt.Fprint(descriptionView, currentTest.Description()) return nil } func quit(g *gocui.Gui, v *gocui.View) error { return gocui.ErrQuit } func runTuiTest(test *components.IntegrationTest, sandbox bool, waitForDebugger bool, raceDetector bool, inputDelay int) { err := components.RunTests(components.RunTestArgs{ Tests: []*components.IntegrationTest{test}, Logf: log.Printf, RunCmd: runCmdInTerminal, TestWrapper: runAndPrintError, Sandbox: sandbox, WaitForDebugger: waitForDebugger, RaceDetector: raceDetector, CodeCoverageDir: "", InputDelay: inputDelay, MaxAttempts: 1, }) if err != nil { log.Println(err.Error()) } } func runAndPrintError(test *components.IntegrationTest, f func() error) { if err := f(); err != nil { log.Println(err.Error()) } } lazygit-0.50.0+ds1/pkg/integration/components/000077500000000000000000000000001500612110400212215ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/components/alert_driver.go000066400000000000000000000021661500612110400242370ustar00rootroot00000000000000package components type AlertDriver struct { t *TestDriver hasCheckedTitle bool hasCheckedContent bool } func (self *AlertDriver) getViewDriver() *ViewDriver { return self.t.Views().Confirmation() } // asserts that the alert view has the expected title func (self *AlertDriver) Title(expected *TextMatcher) *AlertDriver { self.getViewDriver().Title(expected) self.hasCheckedTitle = true return self } // asserts that the alert view has the expected content func (self *AlertDriver) Content(expected *TextMatcher) *AlertDriver { self.getViewDriver().Content(expected) self.hasCheckedContent = true return self } func (self *AlertDriver) Confirm() { self.checkNecessaryChecksCompleted() self.getViewDriver().PressEnter() } func (self *AlertDriver) Cancel() { self.checkNecessaryChecksCompleted() self.getViewDriver().PressEscape() } func (self *AlertDriver) checkNecessaryChecksCompleted() { if !self.hasCheckedContent || !self.hasCheckedTitle { self.t.Fail("You must both check the content and title of a confirmation popup by calling Title()/Content() before calling Confirm()/Cancel().") } } lazygit-0.50.0+ds1/pkg/integration/components/assertion_helper.go000066400000000000000000000014111500612110400251130ustar00rootroot00000000000000package components import ( integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) type assertionHelper struct { gui integrationTypes.GuiDriver } func (self *assertionHelper) matchString(matcher *TextMatcher, context string, getValue func() string) { self.assertWithRetries(func() (bool, string) { value := getValue() return matcher.context(context).test(value) }) } // We no longer assert with retries now that lazygit tells us when it's no longer // busy. But I'm keeping the function in case we want to re-introduce it later. func (self *assertionHelper) assertWithRetries(test func() (bool, string)) { ok, message := test() if !ok { self.fail(message) } } func (self *assertionHelper) fail(message string) { self.gui.Fail(message) } lazygit-0.50.0+ds1/pkg/integration/components/commit_description_panel_driver.go000066400000000000000000000035271500612110400302040ustar00rootroot00000000000000package components type CommitDescriptionPanelDriver struct { t *TestDriver } func (self *CommitDescriptionPanelDriver) getViewDriver() *ViewDriver { return self.t.Views().CommitDescription() } // asserts on the current context of the description func (self *CommitDescriptionPanelDriver) Content(expected *TextMatcher) *CommitDescriptionPanelDriver { self.getViewDriver().Content(expected) return self } func (self *CommitDescriptionPanelDriver) Type(value string) *CommitDescriptionPanelDriver { self.t.typeContent(value) return self } func (self *CommitDescriptionPanelDriver) SwitchToSummary() *CommitMessagePanelDriver { self.getViewDriver().PressTab() return &CommitMessagePanelDriver{t: self.t} } func (self *CommitDescriptionPanelDriver) AddNewline() *CommitDescriptionPanelDriver { self.t.pressFast(self.t.keys.Universal.Confirm) return self } func (self *CommitDescriptionPanelDriver) GoToBeginning() *CommitDescriptionPanelDriver { numLines := len(self.getViewDriver().getView().BufferLines()) for i := 0; i < numLines; i++ { self.t.pressFast("") } self.t.pressFast("") return self } func (self *CommitDescriptionPanelDriver) AddCoAuthor(author string) *CommitDescriptionPanelDriver { self.t.press(self.t.keys.CommitMessage.CommitMenu) self.t.ExpectPopup().Menu().Title(Equals("Commit Menu")). Select(Contains("Add co-author")). Confirm() self.t.ExpectPopup().Prompt().Title(Contains("Add co-author")). Type(author). Confirm() return self } func (self *CommitDescriptionPanelDriver) Clear() *CommitDescriptionPanelDriver { self.getViewDriver().Clear() return self } func (self *CommitDescriptionPanelDriver) Title(expected *TextMatcher) *CommitDescriptionPanelDriver { self.getViewDriver().Title(expected) return self } func (self *CommitDescriptionPanelDriver) Cancel() { self.getViewDriver().PressEscape() } lazygit-0.50.0+ds1/pkg/integration/components/commit_message_panel_driver.go000066400000000000000000000040771500612110400273060ustar00rootroot00000000000000package components type CommitMessagePanelDriver struct { t *TestDriver } func (self *CommitMessagePanelDriver) getViewDriver() *ViewDriver { return self.t.Views().CommitMessage() } // asserts on the text initially present in the prompt func (self *CommitMessagePanelDriver) InitialText(expected *TextMatcher) *CommitMessagePanelDriver { return self.Content(expected) } // asserts on the current context in the prompt func (self *CommitMessagePanelDriver) Content(expected *TextMatcher) *CommitMessagePanelDriver { self.getViewDriver().Content(expected) return self } // asserts that the confirmation view has the expected title func (self *CommitMessagePanelDriver) Title(expected *TextMatcher) *CommitMessagePanelDriver { self.getViewDriver().Title(expected) return self } func (self *CommitMessagePanelDriver) Type(value string) *CommitMessagePanelDriver { self.t.typeContent(value) return self } func (self *CommitMessagePanelDriver) SwitchToDescription() *CommitDescriptionPanelDriver { self.getViewDriver().PressTab() return &CommitDescriptionPanelDriver{t: self.t} } func (self *CommitMessagePanelDriver) Clear() *CommitMessagePanelDriver { self.getViewDriver().Clear() return self } func (self *CommitMessagePanelDriver) Confirm() { self.getViewDriver().PressEnter() } func (self *CommitMessagePanelDriver) Close() { self.getViewDriver().PressEscape() } func (self *CommitMessagePanelDriver) Cancel() { self.getViewDriver().PressEscape() } func (self *CommitMessagePanelDriver) SwitchToEditor() { self.OpenCommitMenu() self.t.ExpectPopup().Menu().Title(Equals("Commit Menu")). Select(Contains("Open in editor")). Confirm() } func (self *CommitMessagePanelDriver) SelectPreviousMessage() *CommitMessagePanelDriver { self.getViewDriver().SelectPreviousItem() return self } func (self *CommitMessagePanelDriver) SelectNextMessage() *CommitMessagePanelDriver { self.getViewDriver().SelectNextItem() return self } func (self *CommitMessagePanelDriver) OpenCommitMenu() *CommitMessagePanelDriver { self.t.press(self.t.keys.CommitMessage.CommitMenu) return self } lazygit-0.50.0+ds1/pkg/integration/components/common.go000066400000000000000000000043421500612110400230430ustar00rootroot00000000000000package components import "fmt" // for running common actions type Common struct { t *TestDriver } func (self *Common) ContinueMerge() { self.t.GlobalPress(self.t.keys.Universal.CreateRebaseOptionsMenu) self.t.ExpectPopup().Menu(). Title(Equals("Rebase options")). Select(Contains("continue")). Confirm() } func (self *Common) ContinueRebase() { self.ContinueMerge() } func (self *Common) AbortRebase() { self.t.GlobalPress(self.t.keys.Universal.CreateRebaseOptionsMenu) self.t.ExpectPopup().Menu(). Title(Equals("Rebase options")). Select(Contains("abort")). Confirm() } func (self *Common) AbortMerge() { self.t.GlobalPress(self.t.keys.Universal.CreateRebaseOptionsMenu) self.t.ExpectPopup().Menu(). Title(Equals("Merge options")). Select(Contains("abort")). Confirm() } func (self *Common) AcknowledgeConflicts() { self.t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). Select(Contains("View conflicts")). Confirm() } func (self *Common) ContinueOnConflictsResolved(command string) { self.t.ExpectPopup().Confirmation(). Title(Equals("Continue")). Content(Contains(fmt.Sprintf("All merge conflicts resolved. Continue the %s?", command))). Confirm() } func (self *Common) ConfirmDiscardLines() { self.t.ExpectPopup().Confirmation(). Title(Equals("Discard change")). Content(Contains("Are you sure you want to discard this change")). Confirm() } func (self *Common) SelectPatchOption(matcher *TextMatcher) { self.t.GlobalPress(self.t.keys.Universal.CreatePatchOptionsMenu) self.t.ExpectPopup().Menu().Title(Equals("Patch options")).Select(matcher).Confirm() } func (self *Common) ResetBisect() { self.t.Views().Commits(). Focus(). Press(self.t.keys.Commits.ViewBisectOptions). Tap(func() { self.t.ExpectPopup().Menu(). Title(Equals("Bisect")). Select(Contains("Reset bisect")). Confirm() self.t.ExpectPopup().Confirmation(). Title(Equals("Reset 'git bisect'")). Content(Contains("Are you sure you want to reset 'git bisect'?")). Confirm() }) } func (self *Common) ResetCustomPatch() { self.t.GlobalPress(self.t.keys.Universal.CreatePatchOptionsMenu) self.t.ExpectPopup().Menu(). Title(Equals("Patch options")). Select(Contains("Reset patch")). Confirm() } lazygit-0.50.0+ds1/pkg/integration/components/confirmation_driver.go000066400000000000000000000025121500612110400256130ustar00rootroot00000000000000package components type ConfirmationDriver struct { t *TestDriver hasCheckedTitle bool hasCheckedContent bool } func (self *ConfirmationDriver) getViewDriver() *ViewDriver { return self.t.Views().Confirmation() } // asserts that the confirmation view has the expected title func (self *ConfirmationDriver) Title(expected *TextMatcher) *ConfirmationDriver { self.getViewDriver().Title(expected) self.hasCheckedTitle = true return self } // asserts that the confirmation view has the expected content func (self *ConfirmationDriver) Content(expected *TextMatcher) *ConfirmationDriver { self.getViewDriver().Content(expected) self.hasCheckedContent = true return self } func (self *ConfirmationDriver) Confirm() { self.checkNecessaryChecksCompleted() self.getViewDriver().PressEnter() } func (self *ConfirmationDriver) Cancel() { self.checkNecessaryChecksCompleted() self.getViewDriver().PressEscape() } func (self *ConfirmationDriver) Wait(milliseconds int) *ConfirmationDriver { self.getViewDriver().Wait(milliseconds) return self } func (self *ConfirmationDriver) checkNecessaryChecksCompleted() { if !self.hasCheckedContent || !self.hasCheckedTitle { self.t.Fail("You must both check the content and title of a confirmation popup by calling Title()/Content() before calling Confirm()/Cancel().") } } lazygit-0.50.0+ds1/pkg/integration/components/env.go000066400000000000000000000041311500612110400223370ustar00rootroot00000000000000package components import ( "fmt" "os" ) const ( // These values will be passed to lazygit LAZYGIT_ROOT_DIR = "LAZYGIT_ROOT_DIR" SANDBOX_ENV_VAR = "SANDBOX" TEST_NAME_ENV_VAR = "TEST_NAME" WAIT_FOR_DEBUGGER_ENV_VAR = "WAIT_FOR_DEBUGGER" // These values will be passed to both lazygit and shell commands GIT_CONFIG_GLOBAL_ENV_VAR = "GIT_CONFIG_GLOBAL" // We pass PWD because if it's defined, Go will use it as the working directory // rather than make a syscall to the OS, and that means symlinks won't be resolved, // which is good to test for. PWD = "PWD" // We set $HOME and $GIT_CONFIG_NOGLOBAL during integration tests so // that older versions of git that don't respect $GIT_CONFIG_GLOBAL // will find the correct global config file for testing HOME = "HOME" GIT_CONFIG_NOGLOBAL = "GIT_CONFIG_NOGLOBAL" // These values will be passed through to lazygit and shell commands, with their // values inherited from the host environment PATH = "PATH" TERM = "TERM" ) // Tests will inherit these environment variables from the host environment, rather // than the test runner deciding the values itself. // All other environment variables present in the host environment will be ignored. // Having such a minimal list ensures that lazygit behaves the same across different test environments. var hostEnvironmentAllowlist = [...]string{ PATH, TERM, } // Returns a copy of the environment filtered by // hostEnvironmentAllowlist func allowedHostEnvironment() []string { env := []string{} for _, envVar := range hostEnvironmentAllowlist { env = append(env, fmt.Sprintf("%s=%s", envVar, os.Getenv(envVar))) } return env } func NewTestEnvironment(rootDir string) []string { env := allowedHostEnvironment() // Set $HOME to control the global git config location for git // versions <= 2.31.8 env = append(env, fmt.Sprintf("%s=%s", HOME, testPath(rootDir))) // $GIT_CONFIG_GLOBAL controls global git config location for git // versions >= 2.32.0 env = append(env, fmt.Sprintf("%s=%s", GIT_CONFIG_GLOBAL_ENV_VAR, globalGitConfigPath(rootDir))) return env } lazygit-0.50.0+ds1/pkg/integration/components/file_system.go000066400000000000000000000026661500612110400241050ustar00rootroot00000000000000package components import ( "fmt" "os" ) type FileSystem struct { *assertionHelper } // This does _not_ check the files panel, it actually checks the filesystem func (self *FileSystem) PathPresent(path string) *FileSystem { self.assertWithRetries(func() (bool, string) { _, err := os.Stat(path) return err == nil, fmt.Sprintf("Expected path '%s' to exist, but it does not", path) }) return self } // This does _not_ check the files panel, it actually checks the filesystem func (self *FileSystem) PathNotPresent(path string) *FileSystem { self.assertWithRetries(func() (bool, string) { _, err := os.Stat(path) return os.IsNotExist(err), fmt.Sprintf("Expected path '%s' to not exist, but it does", path) }) return self } // Asserts that the file at the given path has the given content func (self *FileSystem) FileContent(path string, matcher *TextMatcher) *FileSystem { self.assertWithRetries(func() (bool, string) { _, err := os.Stat(path) if os.IsNotExist(err) { return false, fmt.Sprintf("Expected path '%s' to exist, but it does not", path) } output, err := os.ReadFile(path) if err != nil { return false, fmt.Sprintf("Expected error when reading file content at path '%s': %s", path, err.Error()) } strOutput := string(output) if ok, errMsg := matcher.context("").test(strOutput); !ok { return false, fmt.Sprintf("Unexpected content in file %s: %s", path, errMsg) } return true, "" }) return self } lazygit-0.50.0+ds1/pkg/integration/components/git.go000066400000000000000000000035601500612110400223370ustar00rootroot00000000000000package components import ( "fmt" "log" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" ) type Git struct { *assertionHelper shell *Shell } func (self *Git) CurrentBranchName(expectedName string) *Git { return self.assert([]string{"git", "rev-parse", "--abbrev-ref", "HEAD"}, expectedName) } func (self *Git) TagNamesAt(ref string, expectedNames []string) *Git { return self.assert([]string{"git", "tag", "--sort=v:refname", "--points-at", ref}, strings.Join(expectedNames, "\n")) } func (self *Git) RemoteTagDeleted(ref string, tagName string) *Git { return self.expect([]string{"git", "ls-remote", ref, fmt.Sprintf("refs/tags/%s", tagName)}, func(s string) (bool, string) { return len(s) == 0, fmt.Sprintf("Expected tag %s to have been removed from %s", tagName, ref) }) } func (self *Git) assert(cmdArgs []string, expected string) *Git { self.expect(cmdArgs, func(output string) (bool, string) { return output == expected, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expected, output) }) return self } func (self *Git) expect(cmdArgs []string, condition func(string) (bool, string)) *Git { self.assertWithRetries(func() (bool, string) { output, err := self.shell.runCommandWithOutput(cmdArgs) if err != nil { return false, fmt.Sprintf("Unexpected error running command: `%v`. Error: %s", cmdArgs, err.Error()) } actual := strings.TrimSpace(output) return condition(actual) }) return self } func (self *Git) Version() *git_commands.GitVersion { version, err := getGitVersion() if err != nil { log.Fatalf("Could not get git version: %v", err) } return version } func (self *Git) GetCommitHash(ref string) string { output, err := self.shell.runCommandWithOutput([]string{"git", "rev-parse", ref}) if err != nil { log.Fatalf("Could not get commit hash: %v", err) } return strings.TrimSpace(output) } lazygit-0.50.0+ds1/pkg/integration/components/int_matcher.go000066400000000000000000000023751500612110400240540ustar00rootroot00000000000000package components import ( "fmt" ) type IntMatcher struct { *Matcher[int] } func (self *IntMatcher) EqualsInt(target int) *IntMatcher { self.appendRule(matcherRule[int]{ name: fmt.Sprintf("equals %d", target), testFn: func(value int) (bool, string) { return value == target, fmt.Sprintf("Expected %d to equal %d", value, target) }, }) return self } func (self *IntMatcher) GreaterThan(target int) *IntMatcher { self.appendRule(matcherRule[int]{ name: fmt.Sprintf("greater than %d", target), testFn: func(value int) (bool, string) { return value > target, fmt.Sprintf("Expected %d to greater than %d", value, target) }, }) return self } func (self *IntMatcher) LessThan(target int) *IntMatcher { self.appendRule(matcherRule[int]{ name: fmt.Sprintf("less than %d", target), testFn: func(value int) (bool, string) { return value < target, fmt.Sprintf("Expected %d to less than %d", value, target) }, }) return self } func AnyInt() *IntMatcher { return &IntMatcher{Matcher: &Matcher[int]{}} } func EqualsInt(target int) *IntMatcher { return AnyInt().EqualsInt(target) } func GreaterThan(target int) *IntMatcher { return AnyInt().GreaterThan(target) } func LessThan(target int) *IntMatcher { return AnyInt().LessThan(target) } lazygit-0.50.0+ds1/pkg/integration/components/matcher.go000066400000000000000000000025411500612110400231750ustar00rootroot00000000000000package components import ( "strings" "github.com/samber/lo" ) // for making assertions on string values type Matcher[T any] struct { rules []matcherRule[T] // this is printed when there's an error so that it's clear what the context of the assertion is prefix string } type matcherRule[T any] struct { // e.g. "contains 'foo'" name string // returns a bool that says whether the test passed and if it returns false, it // also returns a string of the error message testFn func(T) (bool, string) } func (self *Matcher[T]) name() string { if len(self.rules) == 0 { return "anything" } return strings.Join( lo.Map(self.rules, func(rule matcherRule[T], _ int) string { return rule.name }), ", ", ) } func (self *Matcher[T]) test(value T) (bool, string) { for _, rule := range self.rules { ok, message := rule.testFn(value) if ok { continue } if self.prefix != "" { return false, self.prefix + " " + message } return false, message } return true, "" } func (self *Matcher[T]) appendRule(rule matcherRule[T]) *Matcher[T] { self.rules = append(self.rules, rule) return self } // adds context so that if the matcher test(s) fails, we understand what we were trying to test. // E.g. prefix: "Unexpected content in view 'files'." func (self *Matcher[T]) context(prefix string) *Matcher[T] { self.prefix = prefix return self } lazygit-0.50.0+ds1/pkg/integration/components/menu_driver.go000066400000000000000000000034331500612110400240720ustar00rootroot00000000000000package components type MenuDriver struct { t *TestDriver hasCheckedTitle bool } func (self *MenuDriver) getViewDriver() *ViewDriver { return self.t.Views().Menu() } // asserts that the popup has the expected title func (self *MenuDriver) Title(expected *TextMatcher) *MenuDriver { self.getViewDriver().Title(expected) self.hasCheckedTitle = true return self } func (self *MenuDriver) Confirm() *MenuDriver { self.checkNecessaryChecksCompleted() self.getViewDriver().PressEnter() return self } func (self *MenuDriver) Cancel() { self.checkNecessaryChecksCompleted() self.getViewDriver().PressEscape() } func (self *MenuDriver) Select(option *TextMatcher) *MenuDriver { self.getViewDriver().NavigateToLine(option) return self } func (self *MenuDriver) Lines(matchers ...*TextMatcher) *MenuDriver { self.getViewDriver().Lines(matchers...) return self } func (self *MenuDriver) TopLines(matchers ...*TextMatcher) *MenuDriver { self.getViewDriver().TopLines(matchers...) return self } func (self *MenuDriver) Filter(text string) *MenuDriver { self.getViewDriver().FilterOrSearch(text) return self } func (self *MenuDriver) LineCount(matcher *IntMatcher) *MenuDriver { self.getViewDriver().LineCount(matcher) return self } func (self *MenuDriver) Wait(milliseconds int) *MenuDriver { self.getViewDriver().Wait(milliseconds) return self } func (self *MenuDriver) Tooltip(option *TextMatcher) *MenuDriver { self.t.Views().Tooltip().Content(option) return self } func (self *MenuDriver) Tap(f func()) *MenuDriver { self.getViewDriver().Tap(f) return self } func (self *MenuDriver) checkNecessaryChecksCompleted() { if !self.hasCheckedTitle { self.t.Fail("You must check the title of a menu popup by calling Title() before calling Confirm()/Cancel().") } } lazygit-0.50.0+ds1/pkg/integration/components/paths.go000066400000000000000000000017571500612110400227010ustar00rootroot00000000000000package components import "path/filepath" // convenience struct for easily getting directories within our test directory. // We have one test directory for each test, found in test/_results. type Paths struct { // e.g. test/_results/test_name root string } func NewPaths(root string) Paths { return Paths{root: root} } // when a test first runs, it's situated in a repo called 'repo' within this // directory. In its setup step, the test is allowed to create other repos // alongside the 'repo' repo in this directory, for example, creating remotes // or repos to add as submodules. func (self Paths) Actual() string { return filepath.Join(self.root, "actual") } // this is the 'repo' directory within the 'actual' directory, // where a lazygit test will start within. func (self Paths) ActualRepo() string { return filepath.Join(self.Actual(), "repo") } func (self Paths) Config() string { return filepath.Join(self.root, "used_config") } func (self Paths) Root() string { return self.root } lazygit-0.50.0+ds1/pkg/integration/components/popup.go000066400000000000000000000044031500612110400227140ustar00rootroot00000000000000package components type Popup struct { t *TestDriver } func (self *Popup) Confirmation() *ConfirmationDriver { self.inConfirm() return &ConfirmationDriver{t: self.t} } func (self *Popup) inConfirm() { self.t.assertWithRetries(func() (bool, string) { currentView := self.t.gui.CurrentContext().GetView() return currentView.Name() == "confirmation" && !currentView.Editable, "Expected confirmation popup to be focused" }) } func (self *Popup) Prompt() *PromptDriver { self.inPrompt() return &PromptDriver{t: self.t} } func (self *Popup) inPrompt() { self.t.assertWithRetries(func() (bool, string) { currentView := self.t.gui.CurrentContext().GetView() return currentView.Name() == "confirmation" && currentView.Editable, "Expected prompt popup to be focused" }) } func (self *Popup) Alert() *AlertDriver { self.inAlert() return &AlertDriver{t: self.t} } func (self *Popup) inAlert() { // basically the same thing as a confirmation popup with the current implementation self.t.assertWithRetries(func() (bool, string) { currentView := self.t.gui.CurrentContext().GetView() return currentView.Name() == "confirmation" && !currentView.Editable, "Expected alert popup to be focused" }) } func (self *Popup) Menu() *MenuDriver { self.inMenu() return &MenuDriver{t: self.t} } func (self *Popup) inMenu() { self.t.assertWithRetries(func() (bool, string) { return self.t.gui.CurrentContext().GetView().Name() == "menu", "Expected popup menu to be focused" }) } func (self *Popup) CommitMessagePanel() *CommitMessagePanelDriver { self.inCommitMessagePanel() return &CommitMessagePanelDriver{t: self.t} } func (self *Popup) CommitDescriptionPanel() *CommitMessagePanelDriver { self.inCommitDescriptionPanel() return &CommitMessagePanelDriver{t: self.t} } func (self *Popup) inCommitMessagePanel() { self.t.assertWithRetries(func() (bool, string) { currentView := self.t.gui.CurrentContext().GetView() return currentView.Name() == "commitMessage", "Expected commit message panel to be focused" }) } func (self *Popup) inCommitDescriptionPanel() { self.t.assertWithRetries(func() (bool, string) { currentView := self.t.gui.CurrentContext().GetView() return currentView.Name() == "commitDescription", "Expected commit description panel to be focused" }) } lazygit-0.50.0+ds1/pkg/integration/components/prompt_driver.go000066400000000000000000000046171500612110400244540ustar00rootroot00000000000000package components type PromptDriver struct { t *TestDriver hasCheckedTitle bool } func (self *PromptDriver) getViewDriver() *ViewDriver { return self.t.Views().Confirmation() } // asserts that the popup has the expected title func (self *PromptDriver) Title(expected *TextMatcher) *PromptDriver { self.getViewDriver().Title(expected) self.hasCheckedTitle = true return self } // asserts on the text initially present in the prompt func (self *PromptDriver) InitialText(expected *TextMatcher) *PromptDriver { self.getViewDriver().Content(expected) return self } func (self *PromptDriver) Type(value string) *PromptDriver { self.t.typeContent(value) return self } func (self *PromptDriver) Clear() *PromptDriver { self.t.press(ClearKey) return self } func (self *PromptDriver) Confirm() { self.checkNecessaryChecksCompleted() self.getViewDriver().PressEnter() } func (self *PromptDriver) Cancel() { self.checkNecessaryChecksCompleted() self.getViewDriver().PressEscape() } func (self *PromptDriver) checkNecessaryChecksCompleted() { if !self.hasCheckedTitle { self.t.Fail("You must check the title of a prompt popup by calling Title() before calling Confirm()/Cancel().") } } func (self *PromptDriver) SuggestionLines(matchers ...*TextMatcher) *PromptDriver { self.t.Views().Suggestions().Lines(matchers...) return self } func (self *PromptDriver) SuggestionTopLines(matchers ...*TextMatcher) *PromptDriver { self.t.Views().Suggestions().TopLines(matchers...) return self } func (self *PromptDriver) ConfirmFirstSuggestion() { self.t.press(self.t.keys.Universal.TogglePanel) self.t.Views().Suggestions(). IsFocused(). SelectedLineIdx(0). PressEnter() } func (self *PromptDriver) ConfirmSuggestion(matcher *TextMatcher) { self.t.press(self.t.keys.Universal.TogglePanel) self.t.Views().Suggestions(). IsFocused(). NavigateToLine(matcher). PressEnter() } func (self *PromptDriver) DeleteSuggestion(matcher *TextMatcher) *PromptDriver { self.t.press(self.t.keys.Universal.TogglePanel) self.t.Views().Suggestions(). IsFocused(). NavigateToLine(matcher) self.t.press(self.t.keys.Universal.Remove) return self } func (self *PromptDriver) EditSuggestion(matcher *TextMatcher) *PromptDriver { self.t.press(self.t.keys.Universal.TogglePanel) self.t.Views().Suggestions(). IsFocused(). NavigateToLine(matcher) self.t.press(self.t.keys.Universal.Edit) return self } lazygit-0.50.0+ds1/pkg/integration/components/random.go000066400000000000000000000411131500612110400230300ustar00rootroot00000000000000package components var RandomCommitMessages = []string{ `Refactor HTTP client for better error handling`, `Integrate pagination in user listings`, `Fix incorrect type in updateUser function`, `Create initial setup for postgres database`, `Add unit tests for authentication service`, `Improve efficiency of sorting algorithm in util package`, `Resolve intermittent test failure in CartTest`, `Introduce cache layer for product images`, `Revamp User Interface of the settings page`, `Remove deprecated uses of api endpoints`, `Ensure proper escaping of SQL queries`, `Implement feature flag for dark mode`, `Add functionality for users to reset password`, `Optimize performance of image loading on home screen`, `Correct argument type in the sendEmail function`, `Merge feature branch 'add-payment-gateway'`, `Add validation to signup form fields`, `Refactor User model to include middle name`, `Update README with new setup instructions`, `Extend session expiry time to 24 hours`, `Implement rate limiting on login attempts`, `Add sorting feature to product listing page`, `Refactor logic in Lazygit Diff view`, `Optimize Lazygit startup time`, `Fix typos in documentation`, `Move global variables to environment config`, `Upgrade Rails version to 6.1.4`, `Refactor user notifications system`, `Implement user blocking functionality`, `Improve Dockerfile for more efficient builds`, `Introduce Redis for session management`, `Ensure CSRF protection for all forms`, `Implement bulk delete feature in admin panel`, `Harden security of user password storage`, `Resolve race condition in transaction handling`, `Migrate legacy codebase to Typescript`, `Update UX of password reset feature`, `Add internationalization support for German`, `Enhance logging in production environment`, `Remove hardcoded values from payment module`, `Introduce retry mechanism in network calls`, `Handle edge case for zero quantity in cart`, `Revamp error handling in user registration`, `Replace deprecated lifecycle methods in React components`, `Update styles according to new design guidelines`, `Handle database connection failures gracefully`, `Ensure atomicity of transactions in payment system`, `Refactor session management using JWT`, `Enhance user search with fuzzy matching`, `Move constants to a separate config file`, `Add TypeScript types to User module`, `Implement automated backups for database`, `Fix broken links on the help page`, `Add end-to-end tests for checkout flow`, `Add loading indicators to improve UX`, `Improve accessibility of site navigation`, `Refactor error messages for better clarity`, `Enable gzip compression for faster page loads`, `Set up CI/CD pipeline using GitHub actions`, `Add a user-friendly 404 page`, `Implement OAuth login with Google`, `Resolve dependency conflicts in package.json`, `Add proper alt text to all images for SEO`, `Implement comment moderation feature`, `Fix double encoding issue in URL parameters`, `Resolve flickering issue in animation`, `Update dependencies to latest stable versions`, `Set proper cache headers for static assets`, `Add structured data for better SEO`, `Refactor to remove circular dependencies`, `Add feature to report inappropriate content`, `Implement mobile-friendly navigation menu`, `Update privacy policy to comply with GDPR`, `Fix memory leak issue in event listeners`, `Improve form validation feedback for user`, `Implement API versioning`, `Improve resilience of system by adding circuit breaker`, `Add sitemap.xml for better search engine indexing`, `Set up performance monitoring with New Relic`, `Introduce service worker for offline support`, `Enhance email notifications with HTML templates`, `Ensure all pages are responsive across devices`, `Create helper functions to reduce code duplication`, `Add 'remember me' feature to login`, `Increase test coverage for User model`, `Refactor error messages into a separate module`, `Optimize images for faster loading`, `Ensure correct HTTP status codes for all responses`, `Implement auto-save feature in post editor`, `Update user guide with new screenshots`, `Implement load testing using Gatling`, `Add keyboard shortcuts for commonly used actions`, `Set up staging environment similar to production`, `Ensure all forms use POST method for data submission`, `Implement soft delete for user accounts`, `Add Webpack for asset bundling`, `Handle session timeout gracefully`, `Remove unused code and libraries`, `Integrate support for markdown in user posts`, `Fix bug in timezone conversion.`, } type RandomFile struct { Name string Content string } var RandomFiles = []RandomFile{ {Name: `http_client.go`, Content: `package httpclient`}, {Name: `user_listings.go`, Content: `package listings`}, {Name: `user_service.go`, Content: `package service`}, {Name: `database_setup.sql`, Content: `CREATE TABLE`}, {Name: `authentication_test.go`, Content: `package auth_test`}, {Name: `utils/sorting.go`, Content: `package utils`}, {Name: `tests/cart_test.go`, Content: `package tests`}, {Name: `cache/product_images.go`, Content: `package cache`}, {Name: `ui/settings_page.jsx`, Content: `import React`}, {Name: `api/deprecated_endpoints.go`, Content: `package api`}, {Name: `db/sql_queries.go`, Content: `package db`}, {Name: `features/dark_mode.go`, Content: `package features`}, {Name: `user/password_reset.go`, Content: `package user`}, {Name: `performance/image_loading.go`, Content: `package performance`}, {Name: `email/send_email.go`, Content: `package email`}, {Name: `merge/payment_gateway.go`, Content: `package merge`}, {Name: `forms/signup_validation.go`, Content: `package forms`}, {Name: `models/user.go`, Content: `package models`}, {Name: `README.md`, Content: `# Project`}, {Name: `config/session.go`, Content: `package config`}, {Name: `security/rate_limit.go`, Content: `package security`}, {Name: `product/sort_list.go`, Content: `package product`}, {Name: `lazygit/diff_view.go`, Content: `package lazygit`}, {Name: `performance/lazygit.go`, Content: `package performance`}, {Name: `docs/documentation.go`, Content: `package docs`}, {Name: `config/global_variables.go`, Content: `package config`}, {Name: `Gemfile`, Content: `source 'https://rubygems.org'`}, {Name: `notification/user_notification.go`, Content: `package notification`}, {Name: `user/blocking.go`, Content: `package user`}, {Name: `Dockerfile`, Content: `FROM ubuntu:18.04`}, {Name: `redis/session_manager.go`, Content: `package redis`}, {Name: `security/csrf_protection.go`, Content: `package security`}, {Name: `admin/bulk_delete.go`, Content: `package admin`}, {Name: `security/password_storage.go`, Content: `package security`}, {Name: `transactions/transaction_handling.go`, Content: `package transactions`}, {Name: `migrations/typescript_migration.go`, Content: `package migrations`}, {Name: `ui/password_reset.jsx`, Content: `import React`}, {Name: `i18n/german.go`, Content: `package i18n`}, {Name: `logging/production_logging.go`, Content: `package logging`}, {Name: `payment/hardcoded_values.go`, Content: `package payment`}, {Name: `network/retry.go`, Content: `package network`}, {Name: `cart/zero_quantity.go`, Content: `package cart`}, {Name: `registration/error_handling.go`, Content: `package registration`}, {Name: `components/deprecated_methods.jsx`, Content: `import React`}, {Name: `styles/new_guidelines.css`, Content: `.class {}`}, {Name: `db/connection_failure.go`, Content: `package db`}, {Name: `payment/transaction_atomicity.go`, Content: `package payment`}, {Name: `session/jwt_management.go`, Content: `package session`}, {Name: `search/fuzzy_matching.go`, Content: `package search`}, {Name: `config/constants.go`, Content: `package config`}, {Name: `models/user_types.go`, Content: `package models`}, {Name: `backup/database_backup.go`, Content: `package backup`}, {Name: `help_page/links.go`, Content: `package help_page`}, {Name: `tests/checkout_test.sql`, Content: `DELETE ALL TABLES;`}, {Name: `ui/loading_indicator.jsx`, Content: `import React`}, {Name: `navigation/site_navigation.go`, Content: `package navigation`}, {Name: `error/error_messages.go`, Content: `package error`}, {Name: `performance/gzip_compression.go`, Content: `package performance`}, {Name: `.github/workflows/ci.yml`, Content: `name: CI`}, {Name: `pages/404.html`, Content: ``}, {Name: `oauth/google_login.go`, Content: `package oauth`}, {Name: `package.json`, Content: `{}`}, {Name: `seo/alt_text.go`, Content: `package seo`}, {Name: `moderation/comment_moderation.go`, Content: `package moderation`}, {Name: `url/double_encoding.go`, Content: `package url`}, {Name: `animation/flickering.go`, Content: `package animation`}, {Name: `upgrade_dependencies.sh`, Content: `#!/bin/sh`}, {Name: `security/csrf_protection2.go`, Content: `package security`}, {Name: `admin/bulk_delete2.go`, Content: `package admin`}, {Name: `security/password_storage2.go`, Content: `package security`}, {Name: `transactions/transaction_handling2.go`, Content: `package transactions`}, {Name: `migrations/typescript_migration2.go`, Content: `package migrations`}, {Name: `ui/password_reset2.jsx`, Content: `import React`}, {Name: `i18n/german2.go`, Content: `package i18n`}, {Name: `logging/production_logging2.go`, Content: `package logging`}, {Name: `payment/hardcoded_values2.go`, Content: `package payment`}, {Name: `network/retry2.go`, Content: `package network`}, {Name: `cart/zero_quantity2.go`, Content: `package cart`}, {Name: `registration/error_handling2.go`, Content: `package registration`}, {Name: `components/deprecated_methods2.jsx`, Content: `import React`}, {Name: `styles/new_guidelines2.css`, Content: `.class {}`}, {Name: `db/connection_failure2.go`, Content: `package db`}, {Name: `payment/transaction_atomicity2.go`, Content: `package payment`}, {Name: `session/jwt_management2.go`, Content: `package session`}, {Name: `search/fuzzy_matching2.go`, Content: `package search`}, {Name: `config/constants2.go`, Content: `package config`}, {Name: `models/user_types2.go`, Content: `package models`}, {Name: `backup/database_backup2.go`, Content: `package backup`}, {Name: `help_page/links2.go`, Content: `package help_page`}, {Name: `tests/checkout_test2.go`, Content: `package tests`}, {Name: `ui/loading_indicator2.jsx`, Content: `import React`}, {Name: `navigation/site_navigation2.go`, Content: `package navigation`}, {Name: `error/error_messages2.go`, Content: `package error`}, {Name: `performance/gzip_compression2.go`, Content: `package performance`}, {Name: `.github/workflows/ci2.yml`, Content: `name: CI`}, {Name: `pages/4042.html`, Content: ``}, {Name: `oauth/google_login2.go`, Content: `package oauth`}, {Name: `package2.json`, Content: `{}`}, {Name: `seo/alt_text2.go`, Content: `package seo`}, {Name: `moderation/comment_moderation2.go`, Content: `package moderation`}, } var RandomFileContents = []string{ `package main import ( "bytes" "fmt" "go/format" "io/fs" "os" "strings" "github.com/samber/lo" ) func main() { code := generateCode() formattedCode, err := format.Source(code) if err != nil { panic(err) } if err := os.WriteFile("test_list.go", formattedCode, 0o644); err != nil { panic(err) } } `, ` package tests import ( "fmt" "os" "path/filepath" "strings" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/samber/lo" ) func GetTests() []*components.IntegrationTest { // first we ensure that each test in this directory has actually been added to the above list. testCount := 0 testNamesSet := set.NewFromSlice(lo.Map( tests, func(test *components.IntegrationTest, _ int) string { return test.Name() }, )) } `, ` package components import ( "os" "strconv" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/config" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // IntegrationTest describes an integration test that will be run against the lazygit gui. // our unit tests will use this description to avoid a panic caused by attempting // to get the test's name via it's file's path. const unitTestDescription = "test test" const ( defaultWidth = 100 defaultHeight = 100 ) `, `package components import ( "fmt" "time" "github.com/atotto/clipboard" "github.com/jesseduffield/lazygit/pkg/config" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) type TestDriver struct { gui integrationTypes.GuiDriver keys config.KeybindingConfig inputDelay int *assertionHelper shell *Shell } func NewTestDriver(gui integrationTypes.GuiDriver, shell *Shell, keys config.KeybindingConfig, inputDelay int) *TestDriver { return &TestDriver{ gui: gui, keys: keys, inputDelay: inputDelay, assertionHelper: &assertionHelper{gui: gui}, shell: shell, } } // key is something like 'w' or ''. It's best not to pass a direct value, // but instead to go through the default user config to get a more meaningful key name func (self *TestDriver) press(keyStr string) { self.SetCaption(fmt.Sprintf("Pressing %s", keyStr)) self.gui.PressKey(keyStr) self.Wait(self.inputDelay) } `, `package updates import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "time" "github.com/go-errors/errors" "github.com/kardianos/osext" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/utils" ) // Updater checks for updates and does updates type Updater struct { *common.Common Config config.AppConfigurer OSCommand *oscommands.OSCommand } // Updaterer implements the check and update methods type Updaterer interface { CheckForNewUpdate() Update() } `, ` package utils import ( "fmt" "regexp" "strings" ) // IsValidEmail checks if an email address is valid func IsValidEmail(email string) bool { // Using a regex pattern to validate email addresses // This is a simple example and might not cover all edge cases emailPattern := ` + "`" + `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + "`" + ` match, _ := regexp.MatchString(emailPattern, email) return match } `, ` package main import ( "fmt" "net/http" "time" "github.com/jesseduffield/lazygit/pkg/utils" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello, the current time is: %s", time.Now().Format(time.RFC3339)) }) port := 8080 utils.PrintMessage(fmt.Sprintf("Server is listening on port %d", port)) http.ListenAndServe(fmt.Sprintf(":%d", port), nil) } `, ` package logging import ( "fmt" "os" "time" ) // LogMessage represents a log message with its timestamp type LogMessage struct { Timestamp time.Time Message string } // Log writes a message to the log file along with a timestamp func Log(message string) { logFile, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { fmt.Println("Error opening log file:", err) return } defer logFile.Close() logEntry := LogMessage{ Timestamp: time.Now(), Message: message, } logLine := fmt.Sprintf("[%s] %s\n", logEntry.Timestamp.Format("2006-01-02 15:04:05"), logEntry.Message) _, err = logFile.WriteString(logLine) if err != nil { fmt.Println("Error writing to log file:", err) } } `, ` package encryption import ( "crypto/aes" "crypto/cipher" "crypto/rand" "errors" "io" ) // Encrypt encrypts a plaintext using AES-GCM encryption func Encrypt(key []byte, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } aesGCM, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, aesGCM.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } ciphertext := aesGCM.Seal(nil, nonce, plaintext, nil) return append(nonce, ciphertext...), nil } `, } var RandomBranchNames = []string{ "hotfix/fix-bug", "r-u-fkn-srs", "iserlohn-build", "hotfix/fezzan-corridor", "terra-investigation", "quash-rebellion", "feature/attack-on-odin", "feature/peace-time", "feature/repair-brunhild", "feature/iserlohn-backdoor", "bugfix/resolve-crash", "enhancement/improve-performance", "experimental/new-feature", "release/v1.0.0", "release/v2.0.0", "chore/update-dependencies", "docs/add-readme", "refactor/cleanup-code", "style/update-css", "test/add-unit-tests", } lazygit-0.50.0+ds1/pkg/integration/components/runner.go000066400000000000000000000174451500612110400230740ustar00rootroot00000000000000package components import ( "fmt" "os" "os/exec" "path/filepath" lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type RunTestArgs struct { Tests []*IntegrationTest Logf func(format string, formatArgs ...interface{}) RunCmd func(cmd *exec.Cmd) (int, error) TestWrapper func(test *IntegrationTest, f func() error) Sandbox bool WaitForDebugger bool RaceDetector bool CodeCoverageDir string InputDelay int MaxAttempts int } // This function lets you run tests either from within `go test` or from a regular binary. // The reason for having two separate ways of testing is that `go test` isn't great at // showing what's actually happening during the test, but it's still good at running // tests in telling you about their results. func RunTests(args RunTestArgs) error { projectRootDir := lazycoreUtils.GetLazyRootDirectory() err := os.Chdir(projectRootDir) if err != nil { return err } testDir := filepath.Join(projectRootDir, "test", "_results") if err := buildLazygit(args); err != nil { return err } gitVersion, err := getGitVersion() if err != nil { return err } for _, test := range args.Tests { args.TestWrapper(test, func() error { //nolint: thelper paths := NewPaths( filepath.Join(testDir, test.Name()), ) for i := 0; i < args.MaxAttempts; i++ { err := runTest(test, args, paths, projectRootDir, gitVersion) if err != nil { if i == args.MaxAttempts-1 { return err } args.Logf("retrying test %s", test.Name()) } else { break } } return nil }) } return nil } func runTest( test *IntegrationTest, args RunTestArgs, paths Paths, projectRootDir string, gitVersion *git_commands.GitVersion, ) error { if test.Skip() { args.Logf("Skipping test %s", test.Name()) return nil } if !test.ShouldRunForGitVersion(gitVersion) { args.Logf("Skipping test %s for git version %d.%d.%d", test.Name(), gitVersion.Major, gitVersion.Minor, gitVersion.Patch) return nil } workingDir, err := prepareTestDir(test, paths, projectRootDir) if err != nil { return err } cmd, err := getLazygitCommand(test, args, paths, projectRootDir, workingDir) if err != nil { return err } pid, err := args.RunCmd(cmd) // Print race detector log regardless of the command's exit status if args.RaceDetector { logPath := fmt.Sprintf("%s.%d", raceDetectorLogsPath(), pid) if bytes, err := os.ReadFile(logPath); err == nil { args.Logf("Race detector log:\n" + string(bytes)) } } return err } func prepareTestDir( test *IntegrationTest, paths Paths, rootDir string, ) (string, error) { findOrCreateDir(paths.Root()) deleteAndRecreateEmptyDir(paths.Actual()) err := os.Mkdir(paths.ActualRepo(), 0o777) if err != nil { return "", err } workingDir := createFixture(test, paths, rootDir) return workingDir, nil } func buildLazygit(testArgs RunTestArgs) error { args := []string{"go", "build"} if testArgs.WaitForDebugger { // Disable compiler optimizations (-N) and inlining (-l) because this // makes debugging work better args = append(args, "-gcflags=all=-N -l") } if testArgs.RaceDetector { args = append(args, "-race") } if testArgs.CodeCoverageDir != "" { args = append(args, "-cover") } args = append(args, "-o", tempLazygitPath(), filepath.FromSlash("pkg/integration/clients/injector/main.go")) osCommand := oscommands.NewDummyOSCommand() return osCommand.Cmd.New(args).Run() } // Sets up the fixture for test and returns the working directory to invoke // lazygit in. func createFixture(test *IntegrationTest, paths Paths, rootDir string) string { env := NewTestEnvironment(rootDir) env = append(env, fmt.Sprintf("%s=%s", PWD, paths.ActualRepo())) shell := NewShell( paths.ActualRepo(), env, func(errorMsg string) { panic(errorMsg) }, ) shell.Init() test.SetupRepo(shell) return shell.dir } func testPath(rootdir string) string { return filepath.Join(rootdir, "test") } func globalGitConfigPath(rootDir string) string { return filepath.Join(testPath(rootDir), "global_git_config") } func getGitVersion() (*git_commands.GitVersion, error) { osCommand := oscommands.NewDummyOSCommand() cmdObj := osCommand.Cmd.New([]string{"git", "--version"}) versionStr, err := cmdObj.RunWithOutput() if err != nil { return nil, err } return git_commands.ParseGitVersion(versionStr) } func getLazygitCommand( test *IntegrationTest, args RunTestArgs, paths Paths, rootDir string, workingDir string, ) (*exec.Cmd, error) { osCommand := oscommands.NewDummyOSCommand() err := os.RemoveAll(paths.Config()) if err != nil { return nil, err } templateConfigDir := filepath.Join(rootDir, "test", "default_test_config") err = oscommands.CopyDir(templateConfigDir, paths.Config()) if err != nil { return nil, err } cmdArgs := []string{tempLazygitPath(), "-debug", "--use-config-dir=" + paths.Config()} resolvedExtraArgs := lo.Map(test.ExtraCmdArgs(), func(arg string, _ int) string { return utils.ResolvePlaceholderString(arg, map[string]string{ "actualPath": paths.Actual(), "actualRepoPath": paths.ActualRepo(), }) }) cmdArgs = append(cmdArgs, resolvedExtraArgs...) // Use a limited environment for test isolation, including pass through // of just allowed host environment variables cmdObj := osCommand.Cmd.NewWithEnviron(cmdArgs, NewTestEnvironment(rootDir)) // Integration tests related to symlink behavior need a PWD that // preserves symlinks. By default, SetWd will set a symlink-resolved // value for PWD. Here, we override that with the path (that may) // contain a symlink to simulate behavior in a user's shell correctly. cmdObj.SetWd(workingDir) cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", PWD, workingDir)) cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", LAZYGIT_ROOT_DIR, rootDir)) if args.CodeCoverageDir != "" { // We set this explicitly here rather than inherit it from the test runner's // environment because the test runner has its own coverage directory that // it writes to and so if we pass GOCOVERDIR to that, it will be overwritten. cmdObj.AddEnvVars("GOCOVERDIR=" + args.CodeCoverageDir) } cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", TEST_NAME_ENV_VAR, test.Name())) if args.Sandbox { cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", SANDBOX_ENV_VAR, "true")) } if args.WaitForDebugger { cmdObj.AddEnvVars(fmt.Sprintf("%s=true", WAIT_FOR_DEBUGGER_ENV_VAR)) } // Set a race detector log path only to avoid spamming the terminal with the // logs. We are not showing this anywhere yet. cmdObj.AddEnvVars(fmt.Sprintf("GORACE=log_path=%s", raceDetectorLogsPath())) if test.ExtraEnvVars() != nil { for key, value := range test.ExtraEnvVars() { cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", key, value)) } } if args.InputDelay > 0 { cmdObj.AddEnvVars(fmt.Sprintf("INPUT_DELAY=%d", args.InputDelay)) } cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", GIT_CONFIG_GLOBAL_ENV_VAR, globalGitConfigPath(rootDir))) return cmdObj.GetCmd(), nil } func tempLazygitPath() string { return filepath.Join("/tmp", "lazygit", "test_lazygit") } func raceDetectorLogsPath() string { return filepath.Join("/tmp", "lazygit", "race_log") } func findOrCreateDir(path string) { _, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { err = os.MkdirAll(path, 0o777) if err != nil { panic(err) } } else { panic(err) } } } func deleteAndRecreateEmptyDir(path string) { // remove contents of integration test directory dir, err := os.ReadDir(path) if err != nil { if os.IsNotExist(err) { err = os.Mkdir(path, 0o777) if err != nil { panic(err) } } else { panic(err) } } for _, d := range dir { os.RemoveAll(filepath.Join(path, d.Name())) } } lazygit-0.50.0+ds1/pkg/integration/components/search_driver.go000066400000000000000000000013461500612110400243740ustar00rootroot00000000000000package components // TODO: soft-code this const ClearKey = "" type SearchDriver struct { t *TestDriver } func (self *SearchDriver) getViewDriver() *ViewDriver { return self.t.Views().Search() } // asserts on the text initially present in the prompt func (self *SearchDriver) InitialText(expected *TextMatcher) *SearchDriver { self.getViewDriver().Content(expected) return self } func (self *SearchDriver) Type(value string) *SearchDriver { self.t.typeContent(value) return self } func (self *SearchDriver) Clear() *SearchDriver { self.t.press(ClearKey) return self } func (self *SearchDriver) Confirm() { self.getViewDriver().PressEnter() } func (self *SearchDriver) Cancel() { self.getViewDriver().PressEscape() } lazygit-0.50.0+ds1/pkg/integration/components/shell.go000066400000000000000000000345011500612110400226620ustar00rootroot00000000000000package components import ( "fmt" "io" "math/rand" "os" "os/exec" "path/filepath" "runtime" "time" ) // this is for running shell commands, mostly for the sake of setting up the repo // but you can also run the commands from within lazygit to emulate things happening // in the background. type Shell struct { // working directory the shell is invoked in dir string // passed into each command env []string // when running the shell outside the gui we can directly panic on failure, // but inside the gui we need to close the gui before panicking fail func(string) randomFileContentIndex int } func NewShell(dir string, env []string, fail func(string)) *Shell { return &Shell{dir: dir, env: env, fail: fail} } func (self *Shell) RunCommand(args []string) *Shell { return self.RunCommandWithEnv(args, []string{}) } // Run a command with additional environment variables set func (self *Shell) RunCommandWithEnv(args []string, env []string) *Shell { output, err := self.runCommandWithOutputAndEnv(args, env) if err != nil { self.fail(fmt.Sprintf("error running command: %v\n%s", args, output)) } return self } func (self *Shell) RunCommandExpectError(args []string) *Shell { output, err := self.runCommandWithOutput(args) if err == nil { self.fail(fmt.Sprintf("Expected error running shell command: %v\n%s", args, output)) } return self } func (self *Shell) runCommandWithOutput(args []string) (string, error) { return self.runCommandWithOutputAndEnv(args, []string{}) } func (self *Shell) runCommandWithOutputAndEnv(args []string, env []string) (string, error) { cmd := exec.Command(args[0], args[1:]...) cmd.Env = append(self.env, env...) cmd.Dir = self.dir output, err := cmd.CombinedOutput() return string(output), err } func (self *Shell) RunShellCommand(cmdStr string) *Shell { shell := "sh" shellArg := "-c" if runtime.GOOS == "windows" { shell = "cmd" shellArg = "/C" } cmd := exec.Command(shell, shellArg, cmdStr) cmd.Env = os.Environ() cmd.Dir = self.dir output, err := cmd.CombinedOutput() if err != nil { self.fail(fmt.Sprintf("error running shell command: %s\n%s", cmdStr, string(output))) } return self } func (self *Shell) CreateFile(path string, content string) *Shell { fullPath := filepath.Join(self.dir, path) // create any required directories dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0o755); err != nil { self.fail(fmt.Sprintf("error creating directory: %s\n%s", dir, err)) } err := os.WriteFile(fullPath, []byte(content), 0o644) if err != nil { self.fail(fmt.Sprintf("error creating file: %s\n%s", fullPath, err)) } return self } func (self *Shell) DeleteFile(path string) *Shell { fullPath := filepath.Join(self.dir, path) err := os.RemoveAll(fullPath) if err != nil { self.fail(fmt.Sprintf("error deleting file: %s\n%s", fullPath, err)) } return self } func (self *Shell) CreateDir(path string) *Shell { fullPath := filepath.Join(self.dir, path) if err := os.MkdirAll(fullPath, 0o755); err != nil { self.fail(fmt.Sprintf("error creating directory: %s\n%s", fullPath, err)) } return self } func (self *Shell) UpdateFile(path string, content string) *Shell { fullPath := filepath.Join(self.dir, path) err := os.WriteFile(fullPath, []byte(content), 0o644) if err != nil { self.fail(fmt.Sprintf("error updating file: %s\n%s", fullPath, err)) } return self } func (self *Shell) NewBranch(name string) *Shell { return self.RunCommand([]string{"git", "checkout", "-b", name}) } func (self *Shell) NewBranchFrom(name string, from string) *Shell { return self.RunCommand([]string{"git", "checkout", "-b", name, from}) } func (self *Shell) RenameCurrentBranch(newName string) *Shell { return self.RunCommand([]string{"git", "branch", "-m", newName}) } func (self *Shell) Checkout(name string) *Shell { return self.RunCommand([]string{"git", "checkout", name}) } func (self *Shell) Merge(name string) *Shell { return self.RunCommand([]string{"git", "merge", "--commit", "--no-ff", name}) } func (self *Shell) ContinueMerge() *Shell { return self.RunCommand([]string{"git", "-c", "core.editor=true", "merge", "--continue"}) } func (self *Shell) GitAdd(path string) *Shell { return self.RunCommand([]string{"git", "add", path}) } func (self *Shell) GitAddAll() *Shell { return self.RunCommand([]string{"git", "add", "-A"}) } func (self *Shell) Commit(message string) *Shell { return self.RunCommand([]string{"git", "commit", "-m", message}) } func (self *Shell) EmptyCommit(message string) *Shell { return self.RunCommand([]string{"git", "commit", "--allow-empty", "-m", message}) } func (self *Shell) EmptyCommitWithBody(subject string, body string) *Shell { return self.RunCommand([]string{"git", "commit", "--allow-empty", "-m", subject, "-m", body}) } func (self *Shell) EmptyCommitDaysAgo(message string, daysAgo int) *Shell { return self.RunCommand([]string{"git", "commit", "--allow-empty", "--date", fmt.Sprintf("%d days ago", daysAgo), "-m", message}) } func (self *Shell) EmptyCommitWithDate(message string, date string) *Shell { env := []string{ "GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date, } return self.RunCommandWithEnv([]string{"git", "commit", "--allow-empty", "-m", message}, env) } func (self *Shell) Revert(ref string) *Shell { return self.RunCommand([]string{"git", "revert", ref}) } func (self *Shell) AssertRemoteTagNotFound(upstream, name string) *Shell { return self.RunCommandExpectError([]string{"git", "ls-remote", "--exit-code", upstream, fmt.Sprintf("refs/tags/%s", name)}) } func (self *Shell) CreateLightweightTag(name string, ref string) *Shell { return self.RunCommand([]string{"git", "tag", name, ref}) } func (self *Shell) CreateAnnotatedTag(name string, message string, ref string) *Shell { return self.RunCommand([]string{"git", "tag", "-a", name, "-m", message, ref}) } func (self *Shell) PushBranch(upstream, branch string) *Shell { return self.RunCommand([]string{"git", "push", upstream, branch}) } func (self *Shell) PushBranchAndSetUpstream(upstream, branch string) *Shell { return self.RunCommand([]string{"git", "push", "--set-upstream", upstream, branch}) } // convenience method for creating a file and adding it func (self *Shell) CreateFileAndAdd(fileName string, fileContents string) *Shell { return self. CreateFile(fileName, fileContents). GitAdd(fileName) } // convenience method for updating a file and adding it func (self *Shell) UpdateFileAndAdd(fileName string, fileContents string) *Shell { return self. UpdateFile(fileName, fileContents). GitAdd(fileName) } // convenience method for deleting a file and adding it func (self *Shell) DeleteFileAndAdd(fileName string) *Shell { return self. DeleteFile(fileName). GitAdd(fileName) } // creates commits 01, 02, 03, ..., n with a new file in each // The reason for padding with zeroes is so that it's easier to do string // matches on the commit messages when there are many of them func (self *Shell) CreateNCommits(n int) *Shell { return self.CreateNCommitsStartingAt(n, 1) } func (self *Shell) CreateNCommitsStartingAt(n, startIndex int) *Shell { for i := startIndex; i < startIndex+n; i++ { self.CreateFileAndAdd( fmt.Sprintf("file%02d.txt", i), fmt.Sprintf("file%02d content", i), ). Commit(fmt.Sprintf("commit %02d", i)) } return self } // Only to be used in demos, because the list might change and we don't want // tests to break when it does. func (self *Shell) CreateNCommitsWithRandomMessages(n int) *Shell { for i := 0; i < n; i++ { file := RandomFiles[i] self.CreateFileAndAdd( file.Name, file.Content, ). Commit(RandomCommitMessages[i]) } return self } // This creates a repo history of commits // It uses a branching strategy where each feature branch is directly branched off // of the master branch // Only to be used in demos func (self *Shell) CreateRepoHistory() *Shell { authors := []string{"Yang Wen-li", "Siegfried Kircheis", "Paul Oberstein", "Oscar Reuenthal", "Fredrica Greenhill"} numAuthors := 5 numBranches := 10 numInitialCommits := 20 maxCommitsPerBranch := 5 // Each commit will happen on a separate day repoStartDaysAgo := 100 totalCommits := 0 // Generate commits for i := 0; i < numInitialCommits; i++ { author := authors[i%numAuthors] commitMessage := RandomCommitMessages[totalCommits%len(RandomCommitMessages)] self.SetAuthor(author, "") self.EmptyCommitDaysAgo(commitMessage, repoStartDaysAgo-totalCommits) totalCommits++ } // Generate branches and merges for i := 0; i < numBranches; i++ { // We'll have one author creating all the commits in the branch author := authors[i%numAuthors] branchName := RandomBranchNames[i%len(RandomBranchNames)] // Choose a random commit within the last 20 commits on the master branch lastMasterCommit := totalCommits - 1 commitOffset := rand.Intn(min(lastMasterCommit, 5)) + 1 // Create the feature branch and checkout the chosen commit self.NewBranchFrom(branchName, fmt.Sprintf("master~%d", commitOffset)) numCommitsInBranch := rand.Intn(maxCommitsPerBranch) + 1 for j := 0; j < numCommitsInBranch; j++ { commitMessage := RandomCommitMessages[totalCommits%len(RandomCommitMessages)] self.SetAuthor(author, "") self.EmptyCommitDaysAgo(commitMessage, repoStartDaysAgo-totalCommits) totalCommits++ } self.Checkout("master") prevCommitterDate := os.Getenv("GIT_COMMITTER_DATE") prevAuthorDate := os.Getenv("GIT_AUTHOR_DATE") commitDate := time.Now().Add(time.Duration(totalCommits-repoStartDaysAgo) * time.Hour * 24) os.Setenv("GIT_COMMITTER_DATE", commitDate.Format(time.RFC3339)) os.Setenv("GIT_AUTHOR_DATE", commitDate.Format(time.RFC3339)) // Merge branch into master self.RunCommand([]string{"git", "merge", "--no-ff", branchName, "-m", fmt.Sprintf("Merge %s into master", branchName)}) os.Setenv("GIT_COMMITTER_DATE", prevCommitterDate) os.Setenv("GIT_AUTHOR_DATE", prevAuthorDate) } return self } // Creates a commit with a random file // Only to be used in demos func (self *Shell) RandomChangeCommit(message string) *Shell { index := self.randomFileContentIndex self.randomFileContentIndex++ randomFileName := fmt.Sprintf("random-%d.go", index) self.CreateFileAndAdd(randomFileName, RandomFileContents[index%len(RandomFileContents)]) return self.Commit(message) } func (self *Shell) SetConfig(key string, value string) *Shell { self.RunCommand([]string{"git", "config", "--local", key, value}) return self } func (self *Shell) CloneIntoRemote(name string) *Shell { self.Clone(name) self.RunCommand([]string{"git", "remote", "add", name, "../" + name}) self.RunCommand([]string{"git", "fetch", name}) return self } func (self *Shell) CloneIntoSubmodule(submoduleName string, submodulePath string) *Shell { self.Clone(submoduleName) self.RunCommand([]string{"git", "submodule", "add", "--name", submoduleName, "../" + submoduleName, submodulePath}) return self } func (self *Shell) Clone(repoName string) *Shell { self.RunCommand([]string{"git", "clone", "--bare", ".", "../" + repoName}) return self } func (self *Shell) CloneNonBare(repoName string) *Shell { self.RunCommand([]string{"git", "clone", ".", "../" + repoName}) return self } func (self *Shell) SetBranchUpstream(branch string, upstream string) *Shell { self.RunCommand([]string{"git", "branch", "--set-upstream-to=" + upstream, branch}) return self } func (self *Shell) RemoveRemoteBranch(remoteName string, branch string) *Shell { self.RunCommand([]string{"git", "-C", "../" + remoteName, "branch", "-d", branch}) return self } func (self *Shell) HardReset(ref string) *Shell { self.RunCommand([]string{"git", "reset", "--hard", ref}) return self } func (self *Shell) Stash(message string) *Shell { self.RunCommand([]string{"git", "stash", "push", "-m", message}) return self } func (self *Shell) StartBisect(good string, bad string) *Shell { self.RunCommand([]string{"git", "bisect", "start", good, bad}) return self } func (self *Shell) Init() *Shell { self.RunCommand([]string{"git", "-c", "init.defaultBranch=master", "init"}) return self } func (self *Shell) AddWorktree(base string, path string, newBranchName string) *Shell { return self.RunCommand([]string{ "git", "worktree", "add", "-b", newBranchName, path, base, }) } // add worktree and have it checkout the base branch func (self *Shell) AddWorktreeCheckout(base string, path string) *Shell { return self.RunCommand([]string{ "git", "worktree", "add", path, base, }) } func (self *Shell) AddFileInWorktree(worktreePath string) *Shell { self.CreateFile(filepath.Join(worktreePath, "content"), "content") self.RunCommand([]string{ "git", "-C", worktreePath, "add", "content", }) return self } func (self *Shell) MakeExecutable(path string) *Shell { // 0755 sets the executable permission for owner, and read/execute permissions for group and others err := os.Chmod(filepath.Join(self.dir, path), 0o755) if err != nil { panic(err) } return self } // Help files are located at test/files from the root the lazygit repo. // E.g. You may want to create a pre-commit hook file there, then call this // function to copy it into your test repo. func (self *Shell) CopyHelpFile(source string, destination string) *Shell { return self.CopyFile(fmt.Sprintf("../../../../../files/%s", source), destination) } func (self *Shell) CopyFile(source string, destination string) *Shell { absSourcePath := filepath.Join(self.dir, source) absDestPath := filepath.Join(self.dir, destination) sourceFile, err := os.Open(absSourcePath) if err != nil { self.fail(err.Error()) } defer sourceFile.Close() destinationFile, err := os.Create(absDestPath) if err != nil { self.fail(err.Error()) } defer destinationFile.Close() _, err = io.Copy(destinationFile, sourceFile) if err != nil { self.fail(err.Error()) } // copy permissions to destination file too sourceFileInfo, err := os.Stat(absSourcePath) if err != nil { self.fail(err.Error()) } err = os.Chmod(absDestPath, sourceFileInfo.Mode()) if err != nil { self.fail(err.Error()) } return self } // The final value passed to Chdir() during setup // will be the directory the test is run from. func (self *Shell) Chdir(path string) *Shell { self.dir = filepath.Join(self.dir, path) return self } func (self *Shell) SetAuthor(authorName string, authorEmail string) *Shell { self.RunCommand([]string{"git", "config", "--local", "user.name", authorName}) self.RunCommand([]string{"git", "config", "--local", "user.email", authorEmail}) return self } lazygit-0.50.0+ds1/pkg/integration/components/test.go000066400000000000000000000146361500612110400225410ustar00rootroot00000000000000package components import ( "os" "strconv" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/config" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) // IntegrationTest describes an integration test that will be run against the lazygit gui. // our unit tests will use this description to avoid a panic caused by attempting // to get the test's name via it's file's path. const unitTestDescription = "test test" const ( defaultWidth = 150 defaultHeight = 100 ) type IntegrationTest struct { name string description string extraCmdArgs []string extraEnvVars map[string]string skip bool setupRepo func(shell *Shell) setupConfig func(config *config.AppConfig) run func( testDriver *TestDriver, keys config.KeybindingConfig, ) gitVersion GitVersionRestriction width int height int isDemo bool } var _ integrationTypes.IntegrationTest = &IntegrationTest{} type NewIntegrationTestArgs struct { // Briefly describes what happens in the test and what it's testing for Description string // prepares a repo for testing SetupRepo func(shell *Shell) // takes a config and mutates. The mutated context will end up being passed to the gui SetupConfig func(config *config.AppConfig) // runs the test Run func(t *TestDriver, keys config.KeybindingConfig) // additional args passed to lazygit ExtraCmdArgs []string ExtraEnvVars map[string]string // for when a test is flakey Skip bool // to run a test only on certain git versions GitVersion GitVersionRestriction // width and height when running in headless mode, for testing // the UI in different sizes. // If these are set, the test must be run in headless mode Width int Height int // If true, this is not a test but a demo to be added to our docs IsDemo bool } type GitVersionRestriction struct { // Only one of these fields can be non-empty; use functions below to construct from string before string includes []string } // Verifies the version is at least the given version (inclusive) func AtLeast(version string) GitVersionRestriction { return GitVersionRestriction{from: version} } // Verifies the version is before the given version (exclusive) func Before(version string) GitVersionRestriction { return GitVersionRestriction{before: version} } func Includes(versions ...string) GitVersionRestriction { return GitVersionRestriction{includes: versions} } func (self GitVersionRestriction) shouldRunOnVersion(version *git_commands.GitVersion) bool { if self.from != "" { from, err := git_commands.ParseGitVersion(self.from) if err != nil { panic("Invalid git version string: " + self.from) } return version.IsAtLeastVersion(from) } if self.before != "" { before, err := git_commands.ParseGitVersion(self.before) if err != nil { panic("Invalid git version string: " + self.before) } return version.IsOlderThanVersion(before) } if len(self.includes) != 0 { return lo.SomeBy(self.includes, func(str string) bool { v, err := git_commands.ParseGitVersion(str) if err != nil { panic("Invalid git version string: " + str) } return version.Major == v.Major && version.Minor == v.Minor && version.Patch == v.Patch }) } return true } func NewIntegrationTest(args NewIntegrationTestArgs) *IntegrationTest { name := "" if args.Description != unitTestDescription { // this panics if we're in a unit test for our integration tests, // so we're using "test test" as a sentinel value name = testNameFromCurrentFilePath() } return &IntegrationTest{ name: name, description: args.Description, extraCmdArgs: args.ExtraCmdArgs, extraEnvVars: args.ExtraEnvVars, skip: args.Skip, setupRepo: args.SetupRepo, setupConfig: args.SetupConfig, run: args.Run, gitVersion: args.GitVersion, width: args.Width, height: args.Height, isDemo: args.IsDemo, } } func (self *IntegrationTest) Name() string { return self.name } func (self *IntegrationTest) Description() string { return self.description } func (self *IntegrationTest) ExtraCmdArgs() []string { return self.extraCmdArgs } func (self *IntegrationTest) ExtraEnvVars() map[string]string { return self.extraEnvVars } func (self *IntegrationTest) Skip() bool { return self.skip } func (self *IntegrationTest) IsDemo() bool { return self.isDemo } func (self *IntegrationTest) ShouldRunForGitVersion(version *git_commands.GitVersion) bool { return self.gitVersion.shouldRunOnVersion(version) } func (self *IntegrationTest) SetupConfig(config *config.AppConfig) { self.setupConfig(config) } func (self *IntegrationTest) SetupRepo(shell *Shell) { self.setupRepo(shell) } func (self *IntegrationTest) Run(gui integrationTypes.GuiDriver) { pwd, err := os.Getwd() if err != nil { panic(err) } shell := NewShell( pwd, // passing the full environment because it's already been filtered down // in the parent process. os.Environ(), func(errorMsg string) { gui.Fail(errorMsg) }, ) keys := gui.Keys() testDriver := NewTestDriver(gui, shell, keys, InputDelay()) if InputDelay() > 0 { // Setting caption to clear the options menu from whatever it starts with testDriver.SetCaption("") testDriver.SetCaptionPrefix("") } self.run(testDriver, keys) gui.CheckAllToastsAcknowledged() if InputDelay() > 0 { // Clear whatever caption there was so it doesn't linger testDriver.SetCaption("") testDriver.SetCaptionPrefix("") // the dev would want to see the final state if they're running in slow mode testDriver.Wait(2000) } } func (self *IntegrationTest) HeadlessDimensions() (int, int) { if self.width == 0 && self.height == 0 { return defaultWidth, defaultHeight } return self.width, self.height } func (self *IntegrationTest) RequiresHeadless() bool { return self.width != 0 && self.height != 0 } func testNameFromCurrentFilePath() string { path := utils.FilePath(3) return TestNameFromFilePath(path) } func TestNameFromFilePath(path string) string { name := strings.Split(path, "integration/tests/")[1] return name[:len(name)-len(".go")] } // this is the delay in milliseconds between keypresses or mouse clicks // defaults to zero func InputDelay() int { delayStr := os.Getenv("INPUT_DELAY") if delayStr == "" { return 0 } delay, err := strconv.Atoi(delayStr) if err != nil { panic(err) } return delay } lazygit-0.50.0+ds1/pkg/integration/components/test_driver.go000066400000000000000000000103201500612110400240760ustar00rootroot00000000000000package components import ( "fmt" "time" "github.com/atotto/clipboard" "github.com/jesseduffield/lazygit/pkg/config" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) type TestDriver struct { gui integrationTypes.GuiDriver keys config.KeybindingConfig inputDelay int *assertionHelper shell *Shell } func NewTestDriver(gui integrationTypes.GuiDriver, shell *Shell, keys config.KeybindingConfig, inputDelay int) *TestDriver { return &TestDriver{ gui: gui, keys: keys, inputDelay: inputDelay, assertionHelper: &assertionHelper{gui: gui}, shell: shell, } } // key is something like 'w' or ''. It's best not to pass a direct value, // but instead to go through the default user config to get a more meaningful key name func (self *TestDriver) press(keyStr string) { self.SetCaption(fmt.Sprintf("Pressing %s", keyStr)) self.gui.PressKey(keyStr) self.Wait(self.inputDelay) } // for use when typing or navigating, because in demos we want that to happen // faster func (self *TestDriver) pressFast(keyStr string) { self.SetCaption("") self.gui.PressKey(keyStr) self.Wait(self.inputDelay / 5) } func (self *TestDriver) click(x, y int) { self.SetCaption(fmt.Sprintf("Clicking %d, %d", x, y)) self.gui.Click(x, y) self.Wait(self.inputDelay) } // Should only be used in specific cases where you're doing something weird! // E.g. invoking a global keybinding from within a popup. // You probably shouldn't use this function, and should instead go through a view like t.Views().Commit().Focus().Press(...) func (self *TestDriver) GlobalPress(keyStr string) { self.press(keyStr) } func (self *TestDriver) typeContent(content string) { for _, char := range content { self.pressFast(string(char)) } } func (self *TestDriver) Common() *Common { return &Common{t: self} } // for when you want to allow lazygit to process something before continuing func (self *TestDriver) Wait(milliseconds int) { time.Sleep(time.Duration(milliseconds) * time.Millisecond) } func (self *TestDriver) SetCaption(caption string) { self.gui.SetCaption(caption) } func (self *TestDriver) SetCaptionPrefix(prefix string) { self.gui.SetCaptionPrefix(prefix) } func (self *TestDriver) LogUI(message string) { self.gui.LogUI(message) } func (self *TestDriver) Log(message string) { self.gui.LogUI(message) } // allows the user to run shell commands during the test to emulate background activity func (self *TestDriver) Shell() *Shell { return self.shell } // for making assertions on lazygit views func (self *TestDriver) Views() *Views { return &Views{t: self} } // for interacting with popups func (self *TestDriver) ExpectPopup() *Popup { return &Popup{t: self} } func (self *TestDriver) ExpectToast(matcher *TextMatcher) *TestDriver { t := self.gui.NextToast() if t == nil { self.gui.Fail("Expected toast, but didn't get one") } else { self.matchString(matcher, "Unexpected toast message", func() string { return *t }, ) } return self } func (self *TestDriver) ExpectClipboard(matcher *TextMatcher) { self.assertWithRetries(func() (bool, string) { text, err := clipboard.ReadAll() if err != nil { return false, "Error occurred when reading from clipboard: " + err.Error() } ok, _ := matcher.test(text) return ok, fmt.Sprintf("Expected clipboard to match %s, but got %s", matcher.name(), text) }) } func (self *TestDriver) ExpectSearch() *SearchDriver { self.inSearch() return &SearchDriver{t: self} } func (self *TestDriver) inSearch() { self.assertWithRetries(func() (bool, string) { currentView := self.gui.CurrentContext().GetView() return currentView.Name() == "search", "Expected search prompt to be focused" }) } // for making assertions through git itself func (self *TestDriver) Git() *Git { return &Git{assertionHelper: self.assertionHelper, shell: self.shell} } // for making assertions on the file system func (self *TestDriver) FileSystem() *FileSystem { return &FileSystem{assertionHelper: self.assertionHelper} } // for when you just want to fail the test yourself. // This runs callbacks to ensure we render the error after closing the gui. func (self *TestDriver) Fail(message string) { self.assertionHelper.fail(message) } lazygit-0.50.0+ds1/pkg/integration/components/test_test.go000066400000000000000000000104431500612110400235700ustar00rootroot00000000000000package components import ( "testing" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" "github.com/stretchr/testify/assert" ) // this file is for testing our test code (meta, I know) type coordinate struct { x, y int } type fakeGuiDriver struct { failureMessage string pressedKeys []string clickedCoordinates []coordinate } var _ integrationTypes.GuiDriver = &fakeGuiDriver{} func (self *fakeGuiDriver) PressKey(key string) { self.pressedKeys = append(self.pressedKeys, key) } func (self *fakeGuiDriver) Click(x, y int) { self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } func (self *fakeGuiDriver) Keys() config.KeybindingConfig { return config.KeybindingConfig{} } func (self *fakeGuiDriver) CurrentContext() types.Context { return nil } func (self *fakeGuiDriver) ContextForView(viewName string) types.Context { return nil } func (self *fakeGuiDriver) Fail(message string) { self.failureMessage = message } func (self *fakeGuiDriver) Log(message string) { } func (self *fakeGuiDriver) LogUI(message string) { } func (self *fakeGuiDriver) CheckedOutRef() *models.Branch { return nil } func (self *fakeGuiDriver) MainView() *gocui.View { return nil } func (self *fakeGuiDriver) SecondaryView() *gocui.View { return nil } func (self *fakeGuiDriver) View(viewName string) *gocui.View { return nil } func (self *fakeGuiDriver) SetCaption(string) { } func (self *fakeGuiDriver) SetCaptionPrefix(string) { } func (self *fakeGuiDriver) NextToast() *string { return nil } func (self *fakeGuiDriver) CheckAllToastsAcknowledged() {} func (self *fakeGuiDriver) Headless() bool { return false } func TestManualFailure(t *testing.T) { test := NewIntegrationTest(NewIntegrationTestArgs{ Description: unitTestDescription, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Fail("blah") }, }) driver := &fakeGuiDriver{} test.Run(driver) assert.Equal(t, "blah", driver.failureMessage) } func TestSuccess(t *testing.T) { test := NewIntegrationTest(NewIntegrationTestArgs{ Description: unitTestDescription, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.press("a") t.press("b") t.click(0, 1) t.click(2, 3) }, }) driver := &fakeGuiDriver{} test.Run(driver) assert.EqualValues(t, []string{"a", "b"}, driver.pressedKeys) assert.EqualValues(t, []coordinate{{0, 1}, {2, 3}}, driver.clickedCoordinates) assert.Equal(t, "", driver.failureMessage) } func TestGitVersionRestriction(t *testing.T) { scenarios := []struct { testName string gitVersion GitVersionRestriction expectedShouldRun bool }{ { testName: "AtLeast, current is newer", gitVersion: AtLeast("2.24.9"), expectedShouldRun: true, }, { testName: "AtLeast, current is same", gitVersion: AtLeast("2.25.0"), expectedShouldRun: true, }, { testName: "AtLeast, current is older", gitVersion: AtLeast("2.26.0"), expectedShouldRun: false, }, { testName: "Before, current is older", gitVersion: Before("2.24.9"), expectedShouldRun: false, }, { testName: "Before, current is same", gitVersion: Before("2.25.0"), expectedShouldRun: false, }, { testName: "Before, current is newer", gitVersion: Before("2.26.0"), expectedShouldRun: true, }, { testName: "Includes, current is included", gitVersion: Includes("2.23.0", "2.25.0"), expectedShouldRun: true, }, { testName: "Includes, current is not included", gitVersion: Includes("2.23.0", "2.27.0"), expectedShouldRun: false, }, } currentGitVersion := git_commands.GitVersion{Major: 2, Minor: 25, Patch: 0} for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { test := NewIntegrationTest(NewIntegrationTestArgs{ Description: unitTestDescription, GitVersion: s.gitVersion, }) shouldRun := test.ShouldRunForGitVersion(¤tGitVersion) assert.Equal(t, shouldRun, s.expectedShouldRun) }) } } lazygit-0.50.0+ds1/pkg/integration/components/text_matcher.go000066400000000000000000000100731500612110400242400ustar00rootroot00000000000000package components import ( "fmt" "regexp" "strings" "github.com/samber/lo" ) type TextMatcher struct { // If you add or change a field here, be sure to update the copy // code in checkIsSelected() *Matcher[string] } func (self *TextMatcher) Contains(target string) *TextMatcher { self.appendRule(matcherRule[string]{ name: fmt.Sprintf("contains '%s'", target), testFn: func(value string) (bool, string) { // everything contains the empty string so we unconditionally return true here if target == "" { return true, "" } return strings.Contains(value, target), fmt.Sprintf("Expected '%s' to be found in '%s'", target, value) }, }) return self } func (self *TextMatcher) DoesNotContain(target string) *TextMatcher { self.appendRule(matcherRule[string]{ name: fmt.Sprintf("does not contain '%s'", target), testFn: func(value string) (bool, string) { return !strings.Contains(value, target), fmt.Sprintf("Expected '%s' to NOT be found in '%s'", target, value) }, }) return self } func (self *TextMatcher) DoesNotContainAnyOf(targets []string) *TextMatcher { self.appendRule(matcherRule[string]{ name: fmt.Sprintf("does not contain any of '%s'", targets), testFn: func(value string) (bool, string) { return lo.NoneBy(targets, func(target string) bool { return strings.Contains(value, target) }), fmt.Sprintf("Expected none of '%s' to be found in '%s'", targets, value) }, }) return self } func (self *TextMatcher) MatchesRegexp(target string) *TextMatcher { self.appendRule(matcherRule[string]{ name: fmt.Sprintf("matches regular expression '%s'", target), testFn: func(value string) (bool, string) { matched, err := regexp.MatchString(target, value) if err != nil { return false, fmt.Sprintf("Unexpected error parsing regular expression '%s': %s", target, err.Error()) } return matched, fmt.Sprintf("Expected '%s' to match regular expression /%s/", value, target) }, }) return self } func (self *TextMatcher) Equals(target string) *TextMatcher { self.appendRule(matcherRule[string]{ name: fmt.Sprintf("equals '%s'", target), testFn: func(value string) (bool, string) { return target == value, fmt.Sprintf("Expected '%s' to equal '%s'", value, target) }, }) return self } const IS_SELECTED_RULE_NAME = "is selected" // special rule that is only to be used in the TopLines and Lines methods, as a way of // asserting that a given line is selected. func (self *TextMatcher) IsSelected() *TextMatcher { self.appendRule(matcherRule[string]{ name: IS_SELECTED_RULE_NAME, testFn: func(value string) (bool, string) { panic("Special IsSelected matcher is not supposed to have its testFn method called. This rule should only be used within the .Lines() and .TopLines() method on a ViewAsserter.") }, }) return self } // if the matcher has an `IsSelected` rule, it returns true, along with the matcher after that rule has been removed func (self *TextMatcher) checkIsSelected() (bool, *TextMatcher) { // copying into a new matcher in case we want to reuse the original later newMatcher := &TextMatcher{Matcher: &Matcher[string]{}} *newMatcher.Matcher = *self.Matcher check := lo.ContainsBy(newMatcher.rules, func(rule matcherRule[string]) bool { return rule.name == IS_SELECTED_RULE_NAME }) newMatcher.rules = lo.Filter(newMatcher.rules, func(rule matcherRule[string], _ int) bool { return rule.name != IS_SELECTED_RULE_NAME }) return check, newMatcher } // this matcher has no rules meaning it always passes the test. Use this // when you don't care what value you're dealing with. func AnyString() *TextMatcher { return &TextMatcher{Matcher: &Matcher[string]{}} } func Contains(target string) *TextMatcher { return AnyString().Contains(target) } func DoesNotContain(target string) *TextMatcher { return AnyString().DoesNotContain(target) } func DoesNotContainAnyOf(targets ...string) *TextMatcher { return AnyString().DoesNotContainAnyOf(targets) } func MatchesRegexp(target string) *TextMatcher { return AnyString().MatchesRegexp(target) } func Equals(target string) *TextMatcher { return AnyString().Equals(target) } lazygit-0.50.0+ds1/pkg/integration/components/view_driver.go000066400000000000000000000465171500612110400241120ustar00rootroot00000000000000package components import ( "fmt" "strings" "github.com/jesseduffield/gocui" "github.com/samber/lo" ) type ViewDriver struct { // context is prepended to any error messages e.g. 'context: "current view"' context string getView func() *gocui.View t *TestDriver } func (self *ViewDriver) getSelectedLines() []string { view := self.t.gui.View(self.getView().Name()) return view.SelectedLines() } func (self *ViewDriver) getSelectedRange() (int, int) { view := self.t.gui.View(self.getView().Name()) return view.SelectedLineRange() } func (self *ViewDriver) getSelectedLineIdx() int { view := self.t.gui.View(self.getView().Name()) return view.SelectedLineIdx() } // asserts that the view has the expected title func (self *ViewDriver) Title(expected *TextMatcher) *ViewDriver { self.t.assertWithRetries(func() (bool, string) { actual := self.getView().Title return expected.context(fmt.Sprintf("%s title", self.context)).test(actual) }) return self } func (self *ViewDriver) Clear() *ViewDriver { // clearing multiple times in case there's multiple lines // (the clear button only clears a single line at a time) maxAttempts := 100 for i := 0; i < maxAttempts+1; i++ { if self.getView().Buffer() == "" { break } self.t.press(ClearKey) if i == maxAttempts { panic("failed to clear view buffer") } } return self } // asserts that the view has lines matching the given matchers. One matcher must be passed for each line. // If you only care about the top n lines, use the TopLines method instead. // If you only care about a subset of lines, use the ContainsLines method instead. func (self *ViewDriver) Lines(matchers ...*TextMatcher) *ViewDriver { self.validateMatchersPassed(matchers) self.LineCount(EqualsInt(len(matchers))) return self.assertLines(0, matchers...) } // asserts that the view has lines matching the given matchers. So if three matchers // are passed, we only check the first three lines of the view. // This method is convenient when you have a list of commits but you only want to // assert on the first couple of commits. func (self *ViewDriver) TopLines(matchers ...*TextMatcher) *ViewDriver { self.validateMatchersPassed(matchers) self.validateEnoughLines(matchers) return self.assertLines(0, matchers...) } // Asserts on the visible lines of the view. // Note, this assumes that the view's viewport is filled with lines func (self *ViewDriver) VisibleLines(matchers ...*TextMatcher) *ViewDriver { self.validateMatchersPassed(matchers) self.validateVisibleLineCount(matchers) // Get the origin of the view and offset that. // Note that we don't do any retrying here so if we want to bring back retry logic // we'll need to update this. originY := self.getView().OriginY() return self.assertLines(originY, matchers...) } // asserts that somewhere in the view there are consecutive lines matching the given matchers. func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver { self.validateMatchersPassed(matchers) self.validateEnoughLines(matchers) self.t.assertWithRetries(func() (bool, string) { content := self.getView().Buffer() lines := strings.Split(content, "\n") startIdx, endIdx := self.getSelectedRange() for i := 0; i < len(lines)-len(matchers)+1; i++ { matches := true for j, matcher := range matchers { checkIsSelected, matcher := matcher.checkIsSelected() // strip the IsSelected matcher out lineIdx := i + j ok, _ := matcher.test(lines[lineIdx]) if !ok { matches = false break } if checkIsSelected { if lineIdx < startIdx || lineIdx > endIdx { matches = false break } } } if matches { return true, "" } } expectedContent := expectedContentFromMatchers(matchers) return false, fmt.Sprintf( "Expected the following to be contained in the staging panel:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----\nSelected range: %d-%d", expectedContent, content, startIdx, endIdx, ) }) return self } func (self *ViewDriver) ContainsColoredText(fgColorStr string, text string) *ViewDriver { self.t.assertWithRetries(func() (bool, string) { view := self.getView() ok := self.getView().ContainsColoredText(fgColorStr, text) if !ok { return false, fmt.Sprintf("expected view '%s' to contain colored text '%s' but it didn't", view.Name(), text) } return true, "" }) return self } func (self *ViewDriver) DoesNotContainColoredText(fgColorStr string, text string) *ViewDriver { self.t.assertWithRetries(func() (bool, string) { view := self.getView() ok := !self.getView().ContainsColoredText(fgColorStr, text) if !ok { return false, fmt.Sprintf("expected view '%s' to NOT contain colored text '%s' but it didn't", view.Name(), text) } return true, "" }) return self } // asserts on the lines that are selected in the view. Don't use the `IsSelected` matcher with this because it's redundant. func (self *ViewDriver) SelectedLines(matchers ...*TextMatcher) *ViewDriver { self.validateMatchersPassed(matchers) self.validateEnoughLines(matchers) self.t.assertWithRetries(func() (bool, string) { selectedLines := self.getSelectedLines() selectedContent := strings.Join(selectedLines, "\n") expectedContent := expectedContentFromMatchers(matchers) if len(selectedLines) != len(matchers) { return false, fmt.Sprintf("Expected the following to be selected:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----", expectedContent, selectedContent) } for i, line := range selectedLines { checkIsSelected, matcher := matchers[i].checkIsSelected() if checkIsSelected { self.t.fail("You cannot use the IsSelected matcher with the SelectedLines method") } ok, message := matcher.test(line) if !ok { return false, fmt.Sprintf("Error: %s. Expected the following to be selected:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----", message, expectedContent, selectedContent) } } return true, "" }) return self } func (self *ViewDriver) validateMatchersPassed(matchers []*TextMatcher) { if len(matchers) < 1 { self.t.fail("'Lines' methods require at least one matcher to be passed as an argument. If you are trying to assert that there are no lines, use .IsEmpty()") } } func (self *ViewDriver) validateEnoughLines(matchers []*TextMatcher) { view := self.getView() self.t.assertWithRetries(func() (bool, string) { lines := view.BufferLines() return len(lines) >= len(matchers), fmt.Sprintf("unexpected number of lines in view '%s'. Expected at least %d, got %d", view.Name(), len(matchers), len(lines)) }) } // assumes the view's viewport is filled with lines func (self *ViewDriver) validateVisibleLineCount(matchers []*TextMatcher) { view := self.getView() self.t.assertWithRetries(func() (bool, string) { count := view.InnerHeight() return count == len(matchers), fmt.Sprintf("unexpected number of visible lines in view '%s'. Expected exactly %d, got %d", view.Name(), len(matchers), count) }) } func (self *ViewDriver) assertLines(offset int, matchers ...*TextMatcher) *ViewDriver { view := self.getView() var expectedStartIdx, expectedEndIdx int foundSelectionStart := false foundSelectionEnd := false expectedSelectedLines := []string{} for matcherIndex, matcher := range matchers { lineIdx := matcherIndex + offset checkIsSelected, matcher := matcher.checkIsSelected() if checkIsSelected { if foundSelectionEnd { self.t.fail("The IsSelected matcher can only be used on a contiguous range of lines.") } if !foundSelectionStart { expectedStartIdx = lineIdx foundSelectionStart = true } expectedSelectedLines = append(expectedSelectedLines, matcher.name()) expectedEndIdx = lineIdx } else if foundSelectionStart { foundSelectionEnd = true } } for matcherIndex, matcher := range matchers { lineIdx := matcherIndex + offset expectSelected, matcher := matcher.checkIsSelected() self.t.matchString(matcher, fmt.Sprintf("Unexpected content in view '%s'.", view.Name()), func() string { return view.BufferLines()[lineIdx] }, ) // If any of the matchers care about the selection, we need to // assert on the selection for each matcher. if foundSelectionStart { self.t.assertWithRetries(func() (bool, string) { startIdx, endIdx := self.getSelectedRange() selected := lineIdx >= startIdx && lineIdx <= endIdx if (selected && expectSelected) || (!selected && !expectSelected) { return true, "" } lines := self.getSelectedLines() return false, fmt.Sprintf( "Unexpected selection in view '%s'. Expected %s to be selected but got %s.\nExpected selected lines:\n---\n%s\n---\n\nActual selected lines:\n---\n%s\n---\n", view.Name(), formatLineRange(expectedStartIdx, expectedEndIdx), formatLineRange(startIdx, endIdx), strings.Join(expectedSelectedLines, "\n"), strings.Join(lines, "\n"), ) }) } } return self } func formatLineRange(from int, to int) string { if from == to { return "line " + fmt.Sprintf("%d", from) } return "lines " + fmt.Sprintf("%d-%d", from, to) } // asserts on the content of the view i.e. the stuff within the view's frame. func (self *ViewDriver) Content(matcher *TextMatcher) *ViewDriver { self.t.matchString(matcher, fmt.Sprintf("%s: Unexpected content.", self.context), func() string { return self.getView().Buffer() }, ) return self } // asserts on the selected line of the view. If you are selecting a range, // you should use the SelectedLines method instead. func (self *ViewDriver) SelectedLine(matcher *TextMatcher) *ViewDriver { self.t.assertWithRetries(func() (bool, string) { selectedLineIdx := self.getSelectedLineIdx() viewLines := self.getView().BufferLines() if selectedLineIdx >= len(viewLines) { return false, fmt.Sprintf("%s: Expected view to have at least %d lines, but it only has %d", self.context, selectedLineIdx+1, len(viewLines)) } value := viewLines[selectedLineIdx] return matcher.context(fmt.Sprintf("%s: Unexpected selected line.", self.context)).test(value) }) return self } // asserts on the index of the selected line. 0 is the first index, representing the line at the top of the view. func (self *ViewDriver) SelectedLineIdx(expected int) *ViewDriver { self.t.assertWithRetries(func() (bool, string) { actual := self.getView().SelectedLineIdx() return expected == actual, fmt.Sprintf("%s: Expected selected line index to be %d, got %d", self.context, expected, actual) }) return self } // focus the view (assumes the view is a side-view) func (self *ViewDriver) Focus() *ViewDriver { viewName := self.getView().Name() type window struct { name string viewNames []string } windows := []window{ {name: "status", viewNames: []string{"status"}}, {name: "files", viewNames: []string{"files", "worktrees", "submodules"}}, {name: "branches", viewNames: []string{"localBranches", "remotes", "tags"}}, {name: "commits", viewNames: []string{"commits", "reflogCommits"}}, {name: "stash", viewNames: []string{"stash"}}, } for windowIndex, window := range windows { if lo.Contains(window.viewNames, viewName) { tabIndex := lo.IndexOf(window.viewNames, viewName) // jump to the desired window self.t.press(self.t.keys.Universal.JumpToBlock[windowIndex]) // assert we're in the window before continuing self.t.assertWithRetries(func() (bool, string) { currentWindowName := self.t.gui.CurrentContext().GetWindowName() // by convention the window is named after the first view in the window return currentWindowName == window.name, fmt.Sprintf("Expected to be in window '%s', but was in '%s'", window.name, currentWindowName) }) // switch to the desired tab currentViewName := self.t.gui.CurrentContext().GetViewName() currentViewTabIndex := lo.IndexOf(window.viewNames, currentViewName) if tabIndex > currentViewTabIndex { for i := 0; i < tabIndex-currentViewTabIndex; i++ { self.t.press(self.t.keys.Universal.NextTab) } } else if tabIndex < currentViewTabIndex { for i := 0; i < currentViewTabIndex-tabIndex; i++ { self.t.press(self.t.keys.Universal.PrevTab) } } // assert that we're now in the expected view self.IsFocused() return self } } self.t.fail(fmt.Sprintf("Cannot focus view %s: Focus() method not implemented", viewName)) return self } // asserts that the view is focused func (self *ViewDriver) IsFocused() *ViewDriver { self.t.assertWithRetries(func() (bool, string) { expected := self.getView().Name() actual := self.t.gui.CurrentContext().GetView().Name() return actual == expected, fmt.Sprintf("%s: Unexpected view focused. Expected %s, got %s", self.context, expected, actual) }) return self } func (self *ViewDriver) Press(keyStr string) *ViewDriver { self.IsFocused() self.t.press(keyStr) return self } func (self *ViewDriver) Delay() *ViewDriver { self.t.Wait(self.t.inputDelay) return self } // for use when typing or navigating, because in demos we want that to happen // faster func (self *ViewDriver) PressFast(keyStr string) *ViewDriver { self.IsFocused() self.t.pressFast(keyStr) return self } func (self *ViewDriver) Click(x, y int) *ViewDriver { offsetX, offsetY, _, _ := self.getView().Dimensions() self.t.click(offsetX+1+x, offsetY+1+y) return self } // i.e. pressing down arrow func (self *ViewDriver) SelectNextItem() *ViewDriver { return self.PressFast(self.t.keys.Universal.NextItem) } // i.e. pressing up arrow func (self *ViewDriver) SelectPreviousItem() *ViewDriver { return self.PressFast(self.t.keys.Universal.PrevItem) } // i.e. pressing '<' func (self *ViewDriver) GotoTop() *ViewDriver { return self.PressFast(self.t.keys.Universal.GotoTop) } // i.e. pressing space func (self *ViewDriver) PressPrimaryAction() *ViewDriver { return self.Press(self.t.keys.Universal.Select) } // i.e. pressing space func (self *ViewDriver) PressEnter() *ViewDriver { return self.Press(self.t.keys.Universal.Confirm) } // i.e. pressing tab func (self *ViewDriver) PressTab() *ViewDriver { return self.Press(self.t.keys.Universal.TogglePanel) } // i.e. pressing escape func (self *ViewDriver) PressEscape() *ViewDriver { return self.Press(self.t.keys.Universal.Return) } // this will look for a list item in the current panel and if it finds it, it will // enter the keypresses required to navigate to it. // The test will fail if: // - the user is not in a list item // - no list item is found containing the given text // - multiple list items are found containing the given text in the initial page of items func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver { self.IsFocused() view := self.getView() lines := view.BufferLines() matchIndex := -1 self.t.assertWithRetries(func() (bool, string) { var matches []string // first we look for a duplicate on the current screen. We won't bother looking beyond that though. for i, line := range lines { ok, _ := matcher.test(line) if ok { matches = append(matches, line) matchIndex = i } } if len(matches) > 1 { return false, fmt.Sprintf("Found %d matches for `%s`, expected only a single match. Matching lines:\n%s", len(matches), matcher.name(), strings.Join(matches, "\n")) } return true, "" }) // If no match was found, it could be that this is a view that renders only // the visible lines. In that case, we jump to the top and then press // down-arrow until we found the match. We simply return the first match we // find, so we have no way to assert that there are no duplicates. if matchIndex == -1 { self.GotoTop() matchIndex = len(lines) } selectedLineIdx := self.getSelectedLineIdx() if selectedLineIdx == matchIndex { return self.SelectedLine(matcher) } // At this point we can't just take the difference of selected and matched // index and press up or down arrow this many times. The reason is that // there might be section headers between those lines, and these will be // skipped when pressing up or down arrow. So we must keep pressing the // arrow key in a loop, and check after each one whether we now reached the // target line. var maxNumKeyPresses int var keyPress func() if selectedLineIdx < matchIndex { maxNumKeyPresses = matchIndex - selectedLineIdx keyPress = func() { self.SelectNextItem() } } else { maxNumKeyPresses = selectedLineIdx - matchIndex keyPress = func() { self.SelectPreviousItem() } } for i := 0; i < maxNumKeyPresses; i++ { keyPress() idx := self.getSelectedLineIdx() // It is important to use view.BufferLines() here and not lines, because it // could change with every keypress. if ok, _ := matcher.test(view.BufferLines()[idx]); ok { return self } } self.t.fail(fmt.Sprintf("Could not navigate to item matching: %s. Lines:\n%s", matcher.name(), strings.Join(view.BufferLines(), "\n"))) return self } // returns true if the view is a list view and it contains no items func (self *ViewDriver) IsEmpty() *ViewDriver { self.t.assertWithRetries(func() (bool, string) { actual := strings.TrimSpace(self.getView().Buffer()) return actual == "", fmt.Sprintf("%s: Unexpected content in view: expected no content. Content: %s", self.context, actual) }) return self } func (self *ViewDriver) LineCount(matcher *IntMatcher) *ViewDriver { view := self.getView() self.t.assertWithRetries(func() (bool, string) { lineCount := self.getLineCount() ok, _ := matcher.test(lineCount) return ok, fmt.Sprintf("unexpected number of lines in view '%s'. Expected %s, got %d", view.Name(), matcher.name(), lineCount) }) return self } func (self *ViewDriver) getLineCount() int { // can't rely entirely on view.BufferLines because it returns 1 even if there's nothing in the view if strings.TrimSpace(self.getView().Buffer()) == "" { return 0 } view := self.getView() return len(view.BufferLines()) } func (self *ViewDriver) IsVisible() *ViewDriver { self.t.assertWithRetries(func() (bool, string) { return self.getView().Visible, fmt.Sprintf("%s: Expected view to be visible, but it was not", self.context) }) return self } func (self *ViewDriver) IsInvisible() *ViewDriver { self.t.assertWithRetries(func() (bool, string) { return !self.getView().Visible, fmt.Sprintf("%s: Expected view to be invisible, but it was not", self.context) }) return self } // will filter or search depending on whether the view supports filtering/searching func (self *ViewDriver) FilterOrSearch(text string) *ViewDriver { self.IsFocused() self.Press(self.t.keys.Universal.StartSearch). Tap(func() { self.t.ExpectSearch(). Clear(). Type(text). Confirm() self.t.Views().Search().IsVisible().Content(Contains(fmt.Sprintf("matches for '%s'", text))) }) return self } func (self *ViewDriver) SetCaption(caption string) *ViewDriver { self.t.gui.SetCaption(caption) return self } func (self *ViewDriver) SetCaptionPrefix(prefix string) *ViewDriver { self.t.gui.SetCaptionPrefix(prefix) return self } func (self *ViewDriver) Wait(milliseconds int) *ViewDriver { if !self.t.gui.Headless() { self.t.Wait(milliseconds) } return self } // for when you want to make some assertion unrelated to the current view // without breaking the method chain func (self *ViewDriver) Tap(f func()) *ViewDriver { f() return self } // This purely exists as a convenience method for those who hate the trailing periods in multi-line method chains func (self *ViewDriver) Self() *ViewDriver { return self } func expectedContentFromMatchers(matchers []*TextMatcher) string { return strings.Join(lo.Map(matchers, func(matcher *TextMatcher, _ int) string { return matcher.name() }), "\n") } lazygit-0.50.0+ds1/pkg/integration/components/views.go000066400000000000000000000065231500612110400227130ustar00rootroot00000000000000package components import ( "fmt" "github.com/jesseduffield/gocui" ) type Views struct { t *TestDriver } func (self *Views) Main() *ViewDriver { return &ViewDriver{ context: "main view", getView: func() *gocui.View { return self.t.gui.MainView() }, t: self.t, } } func (self *Views) Secondary() *ViewDriver { return &ViewDriver{ context: "secondary view", getView: func() *gocui.View { return self.t.gui.SecondaryView() }, t: self.t, } } func (self *Views) regularView(viewName string) *ViewDriver { return &ViewDriver{ context: fmt.Sprintf("%s view", viewName), getView: func() *gocui.View { return self.t.gui.View(viewName) }, t: self.t, } } func (self *Views) patchExplorerViewByName(viewName string) *ViewDriver { return self.regularView(viewName) } func (self *Views) MergeConflicts() *ViewDriver { return self.regularView("mergeConflicts") } func (self *Views) Commits() *ViewDriver { return self.regularView("commits") } func (self *Views) Files() *ViewDriver { return self.regularView("files") } func (self *Views) Worktrees() *ViewDriver { return self.regularView("worktrees") } func (self *Views) Status() *ViewDriver { return self.regularView("status") } func (self *Views) Submodules() *ViewDriver { return self.regularView("submodules") } func (self *Views) Information() *ViewDriver { return self.regularView("information") } func (self *Views) AppStatus() *ViewDriver { return self.regularView("appStatus") } func (self *Views) Branches() *ViewDriver { return self.regularView("localBranches") } func (self *Views) Remotes() *ViewDriver { return self.regularView("remotes") } func (self *Views) RemoteBranches() *ViewDriver { return self.regularView("remoteBranches") } func (self *Views) Tags() *ViewDriver { return self.regularView("tags") } func (self *Views) ReflogCommits() *ViewDriver { return self.regularView("reflogCommits") } func (self *Views) SubCommits() *ViewDriver { return self.regularView("subCommits") } func (self *Views) CommitFiles() *ViewDriver { return self.regularView("commitFiles") } func (self *Views) Stash() *ViewDriver { return self.regularView("stash") } func (self *Views) Staging() *ViewDriver { return self.patchExplorerViewByName("staging") } func (self *Views) StagingSecondary() *ViewDriver { return self.patchExplorerViewByName("stagingSecondary") } func (self *Views) PatchBuilding() *ViewDriver { return self.patchExplorerViewByName("patchBuilding") } func (self *Views) PatchBuildingSecondary() *ViewDriver { // this is not a patch explorer view because you can't actually focus it: it // just renders content return self.regularView("patchBuildingSecondary") } func (self *Views) Menu() *ViewDriver { return self.regularView("menu") } func (self *Views) Confirmation() *ViewDriver { return self.regularView("confirmation") } func (self *Views) CommitMessage() *ViewDriver { return self.regularView("commitMessage") } func (self *Views) CommitDescription() *ViewDriver { return self.regularView("commitDescription") } func (self *Views) Suggestions() *ViewDriver { return self.regularView("suggestions") } func (self *Views) Search() *ViewDriver { return self.regularView("search") } func (self *Views) Tooltip() *ViewDriver { return self.regularView("tooltip") } func (self *Views) Options() *ViewDriver { return self.regularView("options") } lazygit-0.50.0+ds1/pkg/integration/tests/000077500000000000000000000000001500612110400201765ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/bisect/000077500000000000000000000000001500612110400214475ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/bisect/basic.go000066400000000000000000000036251500612110400230650ustar00rootroot00000000000000package bisect import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Basic = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Start a git bisect to find a bad commit", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell. NewBranch("mybranch"). CreateNCommits(10) }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetAppState().GitLogShowGraph = "never" }, Run: func(t *TestDriver, keys config.KeybindingConfig) { markCommitAsBad := func() { t.Views().Commits(). Press(keys.Commits.ViewBisectOptions) t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as bad`)).Confirm() } markCommitAsGood := func() { t.Views().Commits(). Press(keys.Commits.ViewBisectOptions) t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm() } t.Views().Commits(). Focus(). SelectedLine(Contains("CI commit 10")). NavigateToLine(Contains("CI commit 09")). Tap(func() { markCommitAsBad() t.Views().Information().Content(Contains("Bisecting")) }). SelectedLine(Contains("<-- bad")). NavigateToLine(Contains("CI commit 02")). Tap(markCommitAsGood). TopLines(Contains("CI commit 10")). // lazygit will land us in the commit between our good and bad commits. SelectedLine(Contains("CI commit 05").Contains("<-- current")). Tap(markCommitAsBad). SelectedLine(Contains("CI commit 04").Contains("<-- current")). Tap(func() { markCommitAsGood() // commit 5 is the culprit because we marked 4 as good and 5 as bad. t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm() }). IsFocused(). Content(Contains("CI commit 04")) t.Views().Information().Content(DoesNotContain("Bisecting")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/bisect/choose_terms.go000066400000000000000000000051201500612110400244660ustar00rootroot00000000000000package bisect import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ChooseTerms = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Start a git bisect by choosing 'broken/fixed' as bisect terms", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell. NewBranch("mybranch"). CreateNCommits(10) }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetAppState().GitLogShowGraph = "never" }, Run: func(t *TestDriver, keys config.KeybindingConfig) { markCommitAsFixed := func() { t.Views().Commits(). Press(keys.Commits.ViewBisectOptions) t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as fixed`)).Confirm() } markCommitAsBroken := func() { t.Views().Commits(). Press(keys.Commits.ViewBisectOptions) t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as broken`)).Confirm() } t.Views().Commits(). Focus(). SelectedLine(Contains("CI commit 10")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(Contains("Choose bisect terms")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Term for old/good commit:")).Type("broken").Confirm() t.ExpectPopup().Prompt().Title(Equals("Term for new/bad commit:")).Type("fixed").Confirm() }). NavigateToLine(Contains("CI commit 09")). Tap(markCommitAsFixed). SelectedLine(Contains("<-- fixed")). NavigateToLine(Contains("CI commit 02")). Tap(markCommitAsBroken). Lines( Contains("CI commit 10").DoesNotContain("<--"), Contains("CI commit 09").Contains("<-- fixed"), Contains("CI commit 08").DoesNotContain("<--"), Contains("CI commit 07").DoesNotContain("<--"), Contains("CI commit 06").DoesNotContain("<--"), Contains("CI commit 05").Contains("<-- current").IsSelected(), Contains("CI commit 04").DoesNotContain("<--"), Contains("CI commit 03").DoesNotContain("<--"), Contains("CI commit 02").Contains("<-- broken"), Contains("CI commit 01").DoesNotContain("<--"), ). Tap(markCommitAsFixed). SelectedLine(Contains("CI commit 04").Contains("<-- current")). Tap(func() { markCommitAsBroken() // commit 5 is the culprit because we marked 4 as broken and 5 as fixed. t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm() }). IsFocused(). Content(Contains("CI commit 04")) t.Views().Information().Content(DoesNotContain("Bisecting")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/bisect/from_other_branch.go000066400000000000000000000030721500612110400254610ustar00rootroot00000000000000package bisect import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FromOtherBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Opening lazygit when bisect has been started from another branch. There's an issue where we don't reselect the current branch if we mark the current branch as bad so this test side-steps that problem", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell. EmptyCommit("only commit on master"). // this'll ensure we have a master branch NewBranch("other"). CreateNCommits(10). Checkout("master"). StartBisect("other~2", "other~5") }, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Information().Content(Contains("Bisecting")) t.Views().Commits(). Focus(). TopLines( MatchesRegexp(`<-- bad.*commit 08`), MatchesRegexp(`<-- current.*commit 07`), MatchesRegexp(`\?.*commit 06`), MatchesRegexp(`<-- good.*commit 05`), ). SelectNextItem(). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm() t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 08.*Do you want to reset")).Confirm() t.Views().Information().Content(DoesNotContain("Bisecting")) }). // back in master branch which just had the one commit Lines( Contains("only commit on master"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/bisect/skip.go000066400000000000000000000064731500612110400227560ustar00rootroot00000000000000package bisect import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Skip = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Start a git bisect and skip a few commits (selected or current)", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell. CreateNCommits(10) }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetAppState().GitLogShowGraph = "never" }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). SelectedLine(Contains("commit 10")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as bad`)).Confirm() }). NavigateToLine(Contains("commit 01")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm() t.Views().Information().Content(Contains("Bisecting")) }). Lines( Contains("CI commit 10").Contains("<-- bad"), Contains("CI commit 09").DoesNotContain("<--"), Contains("CI commit 08").DoesNotContain("<--"), Contains("CI commit 07").DoesNotContain("<--"), Contains("CI commit 06").DoesNotContain("<--"), Contains("CI commit 05").Contains("<-- current").IsSelected(), Contains("CI commit 04").DoesNotContain("<--"), Contains("CI commit 03").DoesNotContain("<--"), Contains("CI commit 02").DoesNotContain("<--"), Contains("CI commit 01").Contains("<-- good"), ). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")). // Does not show a "Skip selected commit" entry: Lines( Contains("b Mark current commit").Contains("as bad"), Contains("g Mark current commit").Contains("as good"), Contains("s Skip current commit"), Contains("r Reset bisect"), Contains("Cancel"), ). Select(Contains("Skip current commit")).Confirm() }). // Skipping the current commit selects the new current commit: Lines( Contains("CI commit 10").Contains("<-- bad"), Contains("CI commit 09").DoesNotContain("<--"), Contains("CI commit 08").DoesNotContain("<--"), Contains("CI commit 07").DoesNotContain("<--"), Contains("CI commit 06").Contains("<-- current").IsSelected(), Contains("CI commit 05").Contains("<-- skipped"), Contains("CI commit 04").DoesNotContain("<--"), Contains("CI commit 03").DoesNotContain("<--"), Contains("CI commit 02").DoesNotContain("<--"), Contains("CI commit 01").Contains("<-- good"), ). NavigateToLine(Contains("commit 07")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")). // Does show a "Skip selected commit" entry: Lines( Contains("b Mark current commit").Contains("as bad"), Contains("g Mark current commit").Contains("as good"), Contains("s Skip current commit"), Contains("S Skip selected commit"), Contains("r Reset bisect"), Contains("Cancel"), ). Select(Contains("Skip selected commit")).Confirm() }). // Skipping a selected, non-current commit keeps the selection // there: SelectedLine(Contains("CI commit 07").Contains("<-- skipped")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/000077500000000000000000000000001500612110400214335ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/branch/checkout_autostash.go000066400000000000000000000024331500612110400256640ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CheckoutAutostash = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Check out a branch that requires performing autostash", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "a\n\nb") shell.Commit("add file") shell.UpdateFileAndAdd("file", "a\n\nc") shell.Commit("edit last line") shell.Checkout("HEAD^") shell.UpdateFile("file", "b\n\nb") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Lines( Contains("file"), ) t.Views().Branches(). Focus(). Lines( MatchesRegexp(`\*.*HEAD`).IsSelected(), Contains("master"), ). NavigateToLine(Contains("master")). PressPrimaryAction() t.ExpectPopup().Confirmation(). Title(Contains("Autostash?")). Content(Contains("You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)")). Confirm() t.Views().Branches(). Lines( Contains("master").IsSelected(), ) t.Git().CurrentBranchName("master") t.Views().Files(). Lines( Contains("file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/checkout_by_name.go000066400000000000000000000022061500612110400252610ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CheckoutByName = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Try to checkout branch by name. Verify that it also works on the branch with the special name @.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(3). NewBranch("@"). Checkout("master"). EmptyCommit("blah") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("master").IsSelected(), Contains("@"), ). SelectNextItem(). Press(keys.Branches.CheckoutBranchByName). Tap(func() { t.ExpectPopup().Prompt().Title(Equals("Branch name:")).Type("new-branch").Confirm() t.ExpectPopup().Alert().Title(Equals("Branch not found")).Content(Equals("Branch not found. Create a new branch named new-branch?")).Confirm() }). Lines( MatchesRegexp(`\*.*new-branch`).IsSelected(), Contains("master"), Contains("@"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/create_tag.go000066400000000000000000000017541500612110400240670ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CreateTag = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a new tag on branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(10). NewBranch("new-branch"). EmptyCommit("new commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( MatchesRegexp(`\*\s*new-branch`).IsSelected(), MatchesRegexp(`master`), ). SelectNextItem(). Press(keys.Branches.CreateTag) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). Type("new-tag"). Confirm() t.Views().Tags().Focus(). Lines( MatchesRegexp(`new-tag`).IsSelected(), ) t.Git(). TagNamesAt("HEAD", []string{}). TagNamesAt("master", []string{"new-tag"}) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/delete.go000066400000000000000000000165161500612110400232350ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Delete = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Try all combination of local and remote branch deletions", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CloneIntoRemote("origin"). EmptyCommit("blah"). NewBranch("branch-one"). EmptyCommit("on branch-one 01"). PushBranchAndSetUpstream("origin", "branch-one"). EmptyCommit("on branch-one 02"). Checkout("master"). Merge("branch-one"). // branch-one is contained in master, so no delete confirmation NewBranch("branch-two"). EmptyCommit("on branch-two 01"). PushBranchAndSetUpstream("origin", "branch-two"). // branch-two is contained in its own upstream, so no delete confirmation either NewBranchFrom("branch-three", "master"). EmptyCommit("on branch-three 01"). NewBranch("current-head"). // branch-three is contained in the current head, so no delete confirmation EmptyCommit("on current-head"). NewBranchFrom("branch-four", "master"). EmptyCommit("on branch-four 01"). PushBranchAndSetUpstream("origin", "branch-four"). EmptyCommit("on branch-four 02"). // branch-four is not contained in any of these, so we get a delete confirmation NewBranchFrom("branch-five", "master"). EmptyCommit("on branch-five 01"). PushBranchAndSetUpstream("origin", "branch-five"). // branch-five is contained in its own upstream NewBranchFrom("branch-six", "master"). EmptyCommit("on branch-six 01"). PushBranchAndSetUpstream("origin", "branch-six"). EmptyCommit("on branch-six 02"). // branch-six is not contained in any of these, so we get a delete confirmation Checkout("current-head") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("current-head").IsSelected(), Contains("branch-six ↑1"), Contains("branch-five ✓"), Contains("branch-four ↑1"), Contains("branch-three"), Contains("branch-two ✓"), Contains("master"), Contains("branch-one ↑1"), ). // Deleting the current branch is not possible Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Tooltip(Contains("You cannot delete the checked out branch!")). Title(Equals("Delete branch 'current-head'?")). Select(Contains("Delete local branch")). Confirm(). Tap(func() { t.ExpectToast(Contains("You cannot delete the checked out branch!")) }). Cancel() }). // Delete branch-four. This is the only branch that is not fully merged, so we get // a confirmation popup. NavigateToLine(Contains("branch-four")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch-four'?")). Select(Contains("Delete local branch")). Confirm() t.ExpectPopup(). Confirmation(). Title(Equals("Force delete branch")). Content(Equals("'branch-four' is not fully merged. Are you sure you want to delete it?")). Confirm() }). Lines( Contains("current-head"), Contains("branch-six ↑1"), Contains("branch-five ✓"), Contains("branch-three").IsSelected(), Contains("branch-two ✓"), Contains("master"), Contains("branch-one ↑1"), ). // Delete branch-three. This branch is contained in the current head, so this just works // without any confirmation. Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch-three'?")). Select(Contains("Delete local branch")). Confirm() }). Lines( Contains("current-head"), Contains("branch-six ↑1"), Contains("branch-five ✓"), Contains("branch-two ✓").IsSelected(), Contains("master"), Contains("branch-one ↑1"), ). // Delete branch-two. This branch is contained in its own upstream, so this just works // without any confirmation. Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch-two'?")). Select(Contains("Delete local branch")). Confirm() }). Lines( Contains("current-head"), Contains("branch-six ↑1"), Contains("branch-five ✓"), Contains("master").IsSelected(), Contains("branch-one ↑1"), ). // Delete remote branch of branch-one. We only get the normal remote branch confirmation for this one. SelectNextItem(). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch-one'?")). Select(Contains("Delete remote branch")). Confirm() }). Tap(func() { t.ExpectPopup(). Confirmation(). Title(Equals("Delete branch 'branch-one'?")). Content(Equals("Are you sure you want to delete the remote branch 'branch-one' from 'origin'?")). Confirm() }). Tap(func() { checkRemoteBranches(t, keys, "origin", []string{ "branch-five", "branch-four", "branch-six", "branch-two", }) }). Lines( Contains("current-head"), Contains("branch-six ↑1"), Contains("branch-five ✓"), Contains("master"), Contains("branch-one (upstream gone)").IsSelected(), ). // Delete local branch of branch-one. Even though its upstream is gone, we don't get a confirmation // because it is contained in master. Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch-one'?")). Select(Contains("Delete local branch")). Confirm() }). Lines( Contains("current-head"), Contains("branch-six ↑1"), Contains("branch-five ✓"), Contains("master").IsSelected(), ). // Delete both local and remote branch of branch-six. We get the force-delete warning because it is not fully merged. NavigateToLine(Contains("branch-six")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch-six'?")). Select(Contains("Delete local and remote branch")). Confirm() t.ExpectPopup(). Confirmation(). Title(Equals("Delete local and remote branch")). Content(Contains("Are you sure you want to delete both 'branch-six' from your machine, and 'branch-six' from 'origin'?"). Contains("'branch-six' is not fully merged. Are you sure you want to delete it?")). Confirm() }). Lines( Contains("current-head"), Contains("branch-five ✓").IsSelected(), Contains("master"), ). // Delete both local and remote branch of branch-five. We get the same popups, but the confirmation // doesn't contain the force-delete warning. Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch-five'?")). Select(Contains("Delete local and remote branch")). Confirm() t.ExpectPopup(). Confirmation(). Title(Equals("Delete local and remote branch")). Content(Equals("Are you sure you want to delete both 'branch-five' from your machine, and 'branch-five' from 'origin'?"). DoesNotContain("not fully merged")). Confirm() }). Lines( Contains("current-head"), Contains("master").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/delete_multiple.go000066400000000000000000000136161500612110400251460ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DeleteMultiple = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Try some combinations of local and remote branch deletions with a range selection of branches", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetAppState().LocalBranchSortOrder = "alphabetic" }, SetupRepo: func(shell *Shell) { shell. CloneIntoRemote("origin"). CloneIntoRemote("other-remote"). EmptyCommit("blah"). NewBranch("branch-01"). EmptyCommit("on branch-01 01"). PushBranchAndSetUpstream("origin", "branch-01"). EmptyCommit("on branch-01 02"). NewBranch("branch-02"). EmptyCommit("on branch-02 01"). PushBranchAndSetUpstream("origin", "branch-02"). NewBranchFrom("branch-03", "master"). EmptyCommit("on branch-03 01"). NewBranch("current-head"). EmptyCommit("on current-head"). NewBranchFrom("branch-04", "master"). EmptyCommit("on branch-04 01"). PushBranchAndSetUpstream("other-remote", "branch-04"). EmptyCommit("on branch-04 02"). NewBranchFrom("branch-05", "master"). EmptyCommit("on branch-05 01"). PushBranchAndSetUpstream("origin", "branch-05"). NewBranchFrom("branch-06", "master"). EmptyCommit("on branch-06 01"). PushBranch("origin", "branch-06"). PushBranchAndSetUpstream("other-remote", "branch-06"). EmptyCommit("on branch-06 02"). Checkout("current-head") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("current-head").IsSelected(), Contains("branch-01 ↑1"), Contains("branch-02 ✓"), Contains("branch-03"), Contains("branch-04 ↑1"), Contains("branch-05 ✓"), Contains("branch-06 ↑1"), Contains("master"), ). Press(keys.Universal.RangeSelectDown). // Deleting a range that includes the current branch is not possible Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Tooltip(Contains("You cannot delete the checked out branch!")). Title(Equals("Delete selected branches?")). Select(Contains("Delete local branches")). Confirm(). Tap(func() { t.ExpectToast(Contains("You cannot delete the checked out branch!")) }). Cancel() }). // Delete branch-03 and branch-04. 04 is not fully merged, so we get // a confirmation popup. NavigateToLine(Contains("branch-03")). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete selected branches?")). Select(Contains("Delete local branches")). Confirm() t.ExpectPopup(). Confirmation(). Title(Equals("Force delete branch")). Content(Equals("Some of the selected branches are not fully merged. Are you sure you want to delete them?")). Confirm() }). Lines( Contains("current-head"), Contains("branch-01 ↑1"), Contains("branch-02 ✓"), Contains("branch-05 ✓").IsSelected(), Contains("branch-06 ↑1"), Contains("master"), ). // Delete remote branches of branch-05 and branch-06. They are on different remotes. NavigateToLine(Contains("branch-05")). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete selected branches?")). Select(Contains("Delete remote branches")). Confirm() }). Tap(func() { t.ExpectPopup(). Confirmation(). Title(Equals("Delete selected branches?")). Content(Equals("Are you sure you want to delete the remote branches of the selected branches from their respective remotes?")). Confirm() }). Tap(func() { checkRemoteBranches(t, keys, "origin", []string{ "branch-01", "branch-02", "branch-06", }) checkRemoteBranches(t, keys, "other-remote", []string{ "branch-04", }) }). Lines( Contains("current-head"), Contains("branch-01 ↑1"), Contains("branch-02 ✓"), Contains("branch-05 (upstream gone)").IsSelected(), Contains("branch-06 (upstream gone)").IsSelected(), Contains("master"), ). // Try to delete both local and remote branches of branch-02 and // branch-05; not possible because branch-05's upstream is gone Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete selected branches?")). Select(Contains("Delete local and remote branches")). Confirm(). Tap(func() { t.ExpectToast(Contains("Some of the selected branches have no upstream (or the upstream is not stored locally)")) }). Cancel() }). // Delete both local and remote branches of branch-01 and branch-02. We get // the force-delete warning because branch-01 it is not fully merged. NavigateToLine(Contains("branch-01")). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete selected branches?")). Select(Contains("Delete local and remote branches")). Confirm() t.ExpectPopup(). Confirmation(). Title(Equals("Delete local and remote branch")). Content(Contains("Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?"). Contains("Some of the selected branches are not fully merged. Are you sure you want to delete them?")). Confirm() }). Lines( Contains("current-head"), Contains("branch-05 (upstream gone)").IsSelected(), Contains("branch-06 (upstream gone)"), Contains("master"), ). Tap(func() { checkRemoteBranches(t, keys, "origin", []string{ "branch-06", }) checkRemoteBranches(t, keys, "other-remote", []string{ "branch-04", }) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go000066400000000000000000000045201500612110400327430ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DeleteRemoteBranchWithCredentialPrompt = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Delete a remote branch where credentials are required", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneIntoRemote("origin") shell.NewBranch("mybranch") shell.PushBranchAndSetUpstream("origin", "mybranch") // actually getting a password prompt is tricky: it requires SSH'ing into localhost under a newly created, restricted, user. // This is not easy to do in a cross-platform way, nor is it easy to do in a docker container. // If you can think of a way to do it, please let me know! shell.CopyHelpFile("pre-push", ".git/hooks/pre-push") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { deleteBranch := func() { t.Views().Branches(). Focus(). Press(keys.Universal.Remove) t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'mybranch'?")). Select(Contains("Delete remote branch")). Confirm() t.ExpectPopup(). Confirmation(). Title(Equals("Delete branch 'mybranch'?")). Content(Equals("Are you sure you want to delete the remote branch 'mybranch' from 'origin'?")). Confirm() } t.Views().Status().Content(Equals("✓ repo → mybranch")) deleteBranch() // correct credentials are: username=username, password=password t.ExpectPopup().Prompt(). Title(Equals("Username")). Type("username"). Confirm() // enter incorrect password t.ExpectPopup().Prompt(). Title(Equals("Password")). Type("incorrect password"). Confirm() t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("incorrect username/password")). Confirm() t.Views().Status().Content(Equals("✓ repo → mybranch")) // try again with correct password deleteBranch() t.ExpectPopup().Prompt(). Title(Equals("Username")). Type("username"). Confirm() t.ExpectPopup().Prompt(). Title(Equals("Password")). Type("password"). Confirm() t.Views().Status().Content(Equals("(upstream gone) repo → mybranch")) t.Views().Branches().TopLines(Contains("mybranch (upstream gone)")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/delete_remote_branch_with_different_name.go000066400000000000000000000026061500612110400322010ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DeleteRemoteBranchWithDifferentName = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Delete a remote branch that has a different name than the local branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneIntoRemote("origin") shell.NewBranch("mybranch-local") shell.PushBranchAndSetUpstream("origin", "mybranch-local:mybranch-remote") shell.Checkout("master") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("master").IsSelected(), Contains("mybranch-local ✓"), ). SelectNextItem(). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'mybranch-local'?")). Select(Contains("Delete remote branch")). Confirm() }). Tap(func() { t.ExpectPopup(). Confirmation(). Title(Equals("Delete branch 'mybranch-remote'?")). Content(Equals("Are you sure you want to delete the remote branch 'mybranch-remote' from 'origin'?")). Confirm() }). Lines( Contains("master"), Contains("mybranch-local (upstream gone)").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/delete_while_filtering.go000066400000000000000000000024441500612110400264630ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // Regression test for deleting the last branch in the unfiltered list while // filtering is on. This used to cause a segfault. var DeleteWhileFiltering = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Delete a local branch while there's a filter in the branches panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetAppState().LocalBranchSortOrder = "alphabetic" }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.NewBranch("branch1") shell.NewBranch("branch2") shell.Checkout("master") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("master").IsSelected(), Contains("branch1"), Contains("branch2"), ). FilterOrSearch("branch"). Lines( Contains("branch1").IsSelected(), Contains("branch2"), ). SelectNextItem(). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch2'?")). Select(Contains("Delete local branch")). Confirm() }). Lines( Contains("branch1").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/detached_head.go000066400000000000000000000017121500612110400245050ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DetachedHead = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a new branch on detached head", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(10). Checkout("HEAD^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( MatchesRegexp(`\*.*HEAD`).IsSelected(), MatchesRegexp(`master`), ). Press(keys.Universal.New) t.ExpectPopup().Prompt(). Title(MatchesRegexp(`^New branch name \(branch is off of '[0-9a-f]+'\)$`)). Type("new-branch"). Confirm() t.Views().Branches(). Lines( MatchesRegexp(`\* new-branch`).IsSelected(), MatchesRegexp(`master`), ) t.Git().CurrentBranchName("new-branch") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/move_commits_to_new_branch_from_base_branch.go000066400000000000000000000035001500612110400327030ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveCommitsToNewBranchFromBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a new branch from the commits that you accidentally made on the wrong branch; choosing base branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CloneIntoRemote("origin") shell.PushBranchAndSetUpstream("origin", "master") shell.NewBranch("feature") shell.EmptyCommit("feature branch commit") shell.PushBranchAndSetUpstream("origin", "feature") shell.CreateFileAndAdd("file1", "file1 content") shell.Commit("new commit 1") shell.EmptyCommit("new commit 2") shell.UpdateFile("file1", "file1 changed") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Lines( Contains("M file1"), ) t.Views().Branches(). Focus(). Lines( Contains("feature ↑2").IsSelected(), Contains("master ✓"), ). Press(keys.Branches.MoveCommitsToNewBranch) t.ExpectPopup().Menu(). Title(Equals("Move commits to new branch")). Select(Contains("New branch from base branch (origin/master)")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New branch name (branch is off of 'origin/master')")). Type("new branch"). Confirm() t.Views().Branches(). Lines( Contains("new-branch").DoesNotContain("↑").IsSelected(), Contains("feature ✓"), Contains("master ✓"), ) t.Views().Commits(). Lines( Contains("new commit 2").IsSelected(), Contains("new commit 1"), Contains("initial commit"), ) t.Views().Files(). Lines( Contains("M file1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/move_commits_to_new_branch_from_main_branch.go000066400000000000000000000032171500612110400327220ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveCommitsToNewBranchFromMainBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a new branch from the commits that you accidentally made on master", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CloneIntoRemote("origin") shell.PushBranchAndSetUpstream("origin", "master") shell.CreateFileAndAdd("file1", "file1 content") shell.Commit("new commit 1") shell.EmptyCommit("new commit 2") shell.UpdateFile("file1", "file1 changed") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Lines( Contains("M file1"), ) t.Views().Branches(). Focus(). Lines( Contains("master ↑2").IsSelected(), ). Press(keys.Branches.MoveCommitsToNewBranch) t.ExpectPopup().Confirmation(). Title(Equals("Move commits to new branch")). Content(Contains("This will take all unpushed commits and move them to a new branch (off of master).")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New branch name (branch is off of 'master')")). Type("new branch"). Confirm() t.Views().Branches(). Lines( Contains("new-branch").DoesNotContain("↑").IsSelected(), Contains("master ✓"), ) t.Views().Commits(). Lines( Contains("new commit 2").IsSelected(), Contains("new commit 1"), Contains("initial commit"), ) t.Views().Files(). Lines( Contains("M file1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/move_commits_to_new_branch_keep_stacked.go000066400000000000000000000035611500612110400320620ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveCommitsToNewBranchKeepStacked = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a new branch from the commits that you accidentally made on the wrong branch; choosing stacked on current branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CloneIntoRemote("origin") shell.PushBranchAndSetUpstream("origin", "master") shell.NewBranch("feature") shell.EmptyCommit("feature branch commit") shell.PushBranchAndSetUpstream("origin", "feature") shell.CreateFileAndAdd("file1", "file1 content") shell.Commit("new commit 1") shell.EmptyCommit("new commit 2") shell.UpdateFile("file1", "file1 changed") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Lines( Contains("M file1"), ) t.Views().Branches(). Focus(). Lines( Contains("feature ↑2").IsSelected(), Contains("master ✓"), ). Press(keys.Branches.MoveCommitsToNewBranch) t.ExpectPopup().Menu(). Title(Equals("Move commits to new branch")). Select(Contains("New branch stacked on current branch (feature)")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New branch name (branch is off of 'feature')")). Type("new branch"). Confirm() t.Views().Branches(). Lines( Contains("new-branch").DoesNotContain("↑").IsSelected(), Contains("feature ✓"), Contains("master ✓"), ) t.Views().Commits(). Lines( Contains("new commit 2").IsSelected(), Contains("new commit 1"), Contains("* feature branch commit"), Contains("initial commit"), ) t.Views().Files(). Lines( Contains("M file1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/new_branch_autostash.go000066400000000000000000000027061500612110400261700ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NewBranchAutostash = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a new branch that requires performing autostash", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "a\n\nb") shell.Commit("add file") shell.UpdateFileAndAdd("file", "a\n\nc") shell.Commit("edit last line") shell.Checkout("HEAD^") shell.UpdateFile("file", "b\n\nb") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Lines( Contains("file"), ) t.Views().Branches(). Focus(). Lines( MatchesRegexp(`\*.*HEAD`).IsSelected(), Contains("master"), ). NavigateToLine(Contains("master")). Press(keys.Universal.New) t.ExpectPopup().Prompt(). Title(Contains("New branch name (branch is off of 'master')")). Type("new-branch"). Confirm() t.ExpectPopup().Confirmation(). Title(Contains("Autostash?")). Content(Contains("You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)")). Confirm() t.Views().Branches(). Lines( Contains("new-branch").IsSelected(), Contains("master"), ) t.Git().CurrentBranchName("new-branch") t.Views().Files(). Lines( Contains("file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/new_branch_from_remote_tracking_different_name.go000066400000000000000000000024101500612110400333730ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NewBranchFromRemoteTrackingDifferentName = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Set tracking information when creating a new branch from a remote branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("commit") shell.NewBranch("other_branch") shell.CloneIntoRemote("origin") shell.Checkout("master") shell.RunCommand([]string{"git", "branch", "-D", "other_branch"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Remotes(). Focus(). Lines( Contains("origin").IsSelected(), ). PressEnter() t.Views().RemoteBranches(). IsFocused(). Lines( Contains("master").IsSelected(), Contains("other_branch"), ). SelectNextItem(). Press(keys.Universal.New) t.ExpectPopup().Prompt(). Title(Equals("New branch name (branch is off of 'origin/other_branch')")). Clear(). Type("different_name"). Confirm() t.Views().Branches(). Focus(). Lines( Contains("different_name").DoesNotContain("✓"), Contains("master"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/new_branch_from_remote_tracking_same_name.go000066400000000000000000000023241500612110400323560ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NewBranchFromRemoteTrackingSameName = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Set tracking information when creating a new branch from a remote branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("commit") shell.NewBranch("other_branch") shell.CloneIntoRemote("origin") shell.Checkout("master") shell.RunCommand([]string{"git", "branch", "-D", "other_branch"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Remotes(). Focus(). Lines( Contains("origin").IsSelected(), ). PressEnter() t.Views().RemoteBranches(). IsFocused(). Lines( Contains("master").IsSelected(), Contains("other_branch"), ). SelectNextItem(). Press(keys.Universal.New) t.ExpectPopup().Prompt(). Title(Equals("New branch name (branch is off of 'origin/other_branch')")). Confirm() t.Views().Branches(). Focus(). Lines( Contains("other_branch").Contains("✓"), Contains("master"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/new_branch_with_prefix.go000066400000000000000000000016241500612110400265030ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NewBranchWithPrefix = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Creating a new branch from a commit with a default name", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Git.BranchPrefix = "myprefix/" }, SetupRepo: func(shell *Shell) { shell. EmptyCommit("commit 1") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 1").IsSelected(), ). SelectNextItem(). Press(keys.Universal.New). Tap(func() { branchName := "my-branch-name" t.ExpectPopup().Prompt().Title(Contains("New branch name")).Type(branchName).Confirm() t.Git().CurrentBranchName("myprefix/" + branchName) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/new_branch_with_prefix_using_run_command.go000066400000000000000000000017561500612110400323000ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NewBranchWithPrefixUsingRunCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Creating a new branch with a branch prefix using a runCommand", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Git.BranchPrefix = "myprefix/{{ runCommand \"echo dynamic\" }}/" }, SetupRepo: func(shell *Shell) { shell. EmptyCommit("commit 1") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 1").IsSelected(), ). SelectNextItem(). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().Prompt(). Title(Contains("New branch name")). InitialText(Equals("myprefix/dynamic/")). Type("my-branch"). Confirm() t.Git().CurrentBranchName("myprefix/dynamic/my-branch") }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/open_pull_request_invalid_target_remote_name.go000066400000000000000000000032441500612110400331610ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var OpenPullRequestInvalidTargetRemoteName = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open up a pull request, specifying a non-existing target remote", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // Create an initial commit ('git branch set-upstream-to' bails out otherwise) shell.CreateFileAndAdd("file", "content1") shell.Commit("one") // Create a new branch shell.NewBranch("branch-1") // Create a couple of remotes shell.CloneIntoRemote("upstream") shell.CloneIntoRemote("origin") // To allow a pull request to be created from a branch, it must have an upstream set. shell.SetBranchUpstream("branch-1", "origin/branch-1") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // Open a PR for the current branch (i.e. 'branch-1') t.Views(). Branches(). Focus(). Press(keys.Branches.ViewPullRequestOptions) t.ExpectPopup(). Menu(). Title(Equals("View create pull request options")). Select(Contains("Select branch")). Confirm() // Verify that we're prompted to enter the remote and enter the name of a non-existing one. t.ExpectPopup(). Prompt(). Title(Equals("Select target remote")). Type("non-existing-remote"). Confirm() // Verify that this leads to an error being shown (instead of progressing to branch selection). t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("A remote named 'non-existing-remote' does not exist")). Confirm() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/open_pull_request_no_upstream.go000066400000000000000000000013211500612110400301400ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var OpenPullRequestNoUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open up a pull request with a missing upstream branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views(). Branches(). Focus(). Press(keys.Branches.CreatePullRequest) t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("Cannot open a pull request for a branch with no upstream")). Confirm() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/open_pull_request_select_remote_and_target_branch.go000066400000000000000000000051641500612110400341540ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var OpenPullRequestSelectRemoteAndTargetBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open up a pull request, specifying a remote and target branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.OpenLink = "echo {{link}} > /tmp/openlink" }, SetupRepo: func(shell *Shell) { // Create an initial commit ('git branch set-upstream-to' bails out otherwise) shell.CreateFileAndAdd("file", "content1") shell.Commit("one") // Create a new branch and a remote that has that branch shell.NewBranch("branch-1") shell.CloneIntoRemote("upstream") // Create another branch and a second remote. The first remote doesn't have this branch. shell.NewBranch("branch-2") shell.CloneIntoRemote("origin") // To allow a pull request to be created from a branch, it must have an upstream set. shell.SetBranchUpstream("branch-2", "origin/branch-2") shell.RunCommand([]string{"git", "remote", "set-url", "origin", "https://github.com/my-personal-fork/lazygit"}) shell.RunCommand([]string{"git", "remote", "set-url", "upstream", "https://github.com/jesseduffield/lazygit"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // Open a PR for the current branch (i.e. 'branch-2') t.Views(). Branches(). Focus(). Press(keys.Branches.ViewPullRequestOptions) t.ExpectPopup(). Menu(). Title(Equals("View create pull request options")). Select(Contains("Select branch")). Confirm() // Verify that we're prompted to enter the remote t.ExpectPopup(). Prompt(). Title(Equals("Select target remote")). SuggestionLines( Equals("origin"), Equals("upstream")). ConfirmSuggestion(Equals("upstream")) // Verify that we're prompted to enter the target branch and that only those branches // present in the selected remote are listed as suggestions (i.e. 'branch-2' is not there). t.ExpectPopup(). Prompt(). Title(Equals("branch-2 → upstream/")). SuggestionLines( Equals("branch-1"), Equals("master")). ConfirmSuggestion(Equals("master")) // Verify that the expected URL is used (by checking the openlink file) // // Please note that when targeting a different remote - like it's done here in this test - // the link is not yet correct. Thus, this test is expected to fail once this is fixed. t.FileSystem().FileContent( "/tmp/openlink", Equals("https://github.com/my-personal-fork/lazygit/compare/master...branch-2?expand=1\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/open_with_cli_arg.go000066400000000000000000000010021500612110400254270ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var OpenWithCliArg = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open straight to branches panel using a CLI arg", ExtraCmdArgs: []string{"branch"}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches().IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase.go000066400000000000000000000027611500612110400232310ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var Rebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase onto another branch, deal with the conflicts.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.MergeConflictsSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().TopLines( Contains("first change"), Contains("original"), ) t.Views().Branches(). Focus(). Lines( Contains("first-change-branch"), Contains("second-change-branch"), Contains("original-branch"), ). SelectNextItem(). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'first-change-branch'")). Select(Contains("Simple rebase")). Confirm() t.Common().AcknowledgeConflicts() t.Views().Files(). IsFocused(). SelectedLine(Contains("file")). PressEnter() t.Views().MergeConflicts(). IsFocused(). PressPrimaryAction() t.Views().Information().Content(Contains("Rebasing")) t.Common().ContinueOnConflictsResolved("rebase") t.Views().Information().Content(DoesNotContain("Rebasing")) t.Views().Commits().TopLines( Contains("second-change-branch unrelated change"), Contains("second change"), Contains("original"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_abort_on_conflict.go000066400000000000000000000023001500612110400267620ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var RebaseAbortOnConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase onto another branch, abort when there are conflicts.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.MergeConflictsSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().TopLines( Contains("first change"), Contains("original"), ) t.Views().Branches(). Focus(). Lines( Contains("first-change-branch"), Contains("second-change-branch"), Contains("original-branch"), ). SelectNextItem(). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'first-change-branch'")). Select(Contains("Simple rebase")). Confirm() t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). Select(Contains("Abort the rebase")). Confirm() t.Views().Branches(). IsFocused() t.Views().Files(). IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_and_drop.go000066400000000000000000000051561500612110400251000ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var RebaseAndDrop = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase onto another branch, deal with the conflicts. Also mark a commit to be dropped before continuing.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.MergeConflictsSetup(shell) // adding a couple additional commits so that we can drop one shell.EmptyCommit("to remove") shell.EmptyCommit("to keep") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). TopLines( Contains("to keep"), Contains("to remove"), Contains("first change"), Contains("original"), ) t.Views().Branches(). Focus(). Lines( Contains("first-change-branch").IsSelected(), Contains("second-change-branch"), Contains("original-branch"), ). SelectNextItem(). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'first-change-branch'")). Select(Contains("Simple rebase")). Confirm() t.Views().Information().Content(Contains("Rebasing")) t.Common().AcknowledgeConflicts() t.Views().Files().IsFocused(). SelectedLine(MatchesRegexp("UU.*file")) t.Views().Commits(). Focus(). TopLines( Contains("--- Pending rebase todos ---"), MatchesRegexp(`pick.*to keep`).IsSelected(), MatchesRegexp(`pick.*to remove`), MatchesRegexp(`pick.*CONFLICT.*first change`), Contains("--- Commits ---"), MatchesRegexp("second-change-branch unrelated change"), MatchesRegexp("second change"), MatchesRegexp("original"), ). SelectNextItem(). Press(keys.Universal.Remove). TopLines( Contains("--- Pending rebase todos ---"), MatchesRegexp(`pick.*to keep`), MatchesRegexp(`drop.*to remove`).IsSelected(), MatchesRegexp(`pick.*CONFLICT.*first change`), Contains("--- Commits ---"), MatchesRegexp("second-change-branch unrelated change"), MatchesRegexp("second change"), MatchesRegexp("original"), ) t.Views().Files(). Focus(). PressEnter() t.Views().MergeConflicts(). IsFocused(). PressPrimaryAction() t.Common().ContinueOnConflictsResolved("rebase") t.Views().Information().Content(DoesNotContain("Rebasing")) t.Views().Commits().TopLines( Contains("to keep"), Contains("second-change-branch unrelated change").IsSelected(), Contains("second change"), Contains("original"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_cancel_on_conflict.go000066400000000000000000000023341500612110400271070ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var RebaseCancelOnConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase onto another branch, cancel when there are conflicts.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.MergeConflictsSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().TopLines( Contains("first change"), Contains("original"), ) t.Views().Branches(). Focus(). Lines( Contains("first-change-branch"), Contains("second-change-branch"), Contains("original-branch"), ). SelectNextItem(). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'first-change-branch'")). Select(Contains("Simple rebase")). Confirm() t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). Select(Contains("Abort the rebase")). Cancel() t.Views().Branches(). IsFocused() t.Views().Files(). Lines( Contains("UU file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors.go000066400000000000000000000041011500612110400307040ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var RebaseConflictsFixBuildErrors = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase onto another branch, deal with the conflicts. While continue prompt is showing, fix build errors; get another prompt when continuing.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.MergeConflictsSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().TopLines( Contains("first change"), Contains("original"), ) t.Views().Branches(). Focus(). Lines( Contains("first-change-branch"), Contains("second-change-branch"), Contains("original-branch"), ). SelectNextItem(). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'first-change-branch'")). Select(Contains("Simple rebase")). Confirm() t.Common().AcknowledgeConflicts() t.Views().Files(). IsFocused(). SelectedLine(Contains("file")). PressEnter() t.Views().MergeConflicts(). IsFocused(). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Rebasing")) popup := t.ExpectPopup().Confirmation(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the rebase?")) // While the popup is showing, fix some build errors t.Shell().UpdateFile("file", "make it compile again") // Continue popup.Confirm() t.ExpectPopup().Confirmation(). Title(Equals("Continue")). Content(Contains("Files have been modified since conflicts were resolved. Auto-stage them and continue?")). Confirm() t.Views().Information().Content(DoesNotContain("Rebasing")) t.Views().Commits().TopLines( Contains("first change"), Contains("second-change-branch unrelated change"), Contains("second change"), Contains("original"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_copied_branch.go000066400000000000000000000032071500612110400260650ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RebaseCopiedBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Make a copy of a branch, rebase it, check that the original branch is unaffected", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. EmptyCommit("master 1"). EmptyCommit("master 2"). NewBranchFrom("branch1", "master^"). EmptyCommit("branch 1"). EmptyCommit("branch 2"). NewBranch("branch2") shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Lines( Contains("CI * branch 2"), Contains("CI branch 1"), Contains("CI master 1"), ) t.Views().Branches(). Focus(). Lines( Contains("branch2").IsSelected(), Contains("branch1"), Contains("master"), ). NavigateToLine(Contains("master")). Press(keys.Branches.RebaseBranch). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Rebase 'branch2'")). Select(Contains("Simple rebase")). Confirm() }) t.Views().Commits().Lines( Contains("CI branch 2"), Contains("CI branch 1"), Contains("CI master 2"), Contains("CI master 1"), ) t.Views().Branches(). Focus(). NavigateToLine(Contains("branch1")). PressPrimaryAction() t.Views().Commits().Lines( Contains("CI branch 2"), Contains("CI branch 1"), Contains("CI master 1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_does_not_autosquash.go000066400000000000000000000026041500612110400273740ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RebaseDoesNotAutosquash = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase a branch that has fixups onto another branch, and verify that the fixups are not squashed even if rebase.autoSquash is enabled globally.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.SetConfig("rebase.autoSquash", "true") shell. EmptyCommit("base"). NewBranch("my-branch"). Checkout("master"). EmptyCommit("master commit"). Checkout("my-branch"). EmptyCommit("branch commit"). EmptyCommit("fixup! branch commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("fixup! branch commit"), Contains("branch commit"), Contains("base"), ) t.Views().Branches(). Focus(). Lines( Contains("my-branch").IsSelected(), Contains("master"), ). SelectNextItem(). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'my-branch'")). Select(Contains("Simple rebase")). Confirm() t.Views().Commits().Lines( Contains("fixup! branch commit"), Contains("branch commit"), Contains("master commit"), Contains("base"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_from_marked_base.go000066400000000000000000000043041500612110400265640ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RebaseFromMarkedBase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase onto another branch from a marked base commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. NewBranch("base-branch"). EmptyCommit("one"). EmptyCommit("two"). EmptyCommit("three"). NewBranch("active-branch"). EmptyCommit("active one"). EmptyCommit("active two"). EmptyCommit("active three"). Checkout("base-branch"). NewBranch("target-branch"). EmptyCommit("target one"). EmptyCommit("target two"). Checkout("active-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("active three"), Contains("active two"), Contains("active one"), Contains("three"), Contains("two"), Contains("one"), ). NavigateToLine(Contains("active one")). Press(keys.Commits.MarkCommitAsBaseForRebase). Lines( Contains("active three").Contains("✓"), Contains("active two").Contains("✓"), Contains("↑↑↑ Will rebase from here ↑↑↑ active one"), Contains("three").DoesNotContain("✓"), Contains("two").DoesNotContain("✓"), Contains("one").DoesNotContain("✓"), ) t.Views().Information().Content(Contains("Marked a base commit for rebase")) t.Views().Branches(). Focus(). Lines( Contains("active-branch"), Contains("target-branch"), Contains("base-branch"), ). SelectNextItem(). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'active-branch' from marked base")). Select(Contains("Simple rebase")). Confirm() t.Views().Commits().Lines( Contains("active three").DoesNotContain("✓"), Contains("active two").DoesNotContain("✓"), Contains("target two").DoesNotContain("✓"), Contains("target one").DoesNotContain("✓"), Contains("three").DoesNotContain("✓"), Contains("two").DoesNotContain("✓"), Contains("one").DoesNotContain("✓"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_onto_base_branch.go000066400000000000000000000024641500612110400265770ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RebaseOntoBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase the current branch onto its base branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.ShowDivergenceFromBaseBranch = "arrowAndNumber" }, SetupRepo: func(shell *Shell) { shell. EmptyCommit("master 1"). EmptyCommit("master 2"). EmptyCommit("master 3"). NewBranchFrom("feature", "master^"). EmptyCommit("feature 1"). EmptyCommit("feature 2") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Lines( Contains("feature 2"), Contains("feature 1"), Contains("master 2"), Contains("master 1"), ) t.Views().Branches(). Focus(). Lines( Contains("feature ↓1").IsSelected(), Contains("master"), ). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'feature'")). Select(Contains("Rebase onto base branch (master)")). Confirm() t.Views().Commits().Lines( Contains("feature 2"), Contains("feature 1"), Contains("master 3"), Contains("master 2"), Contains("master 1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rebase_to_upstream.go000066400000000000000000000045601500612110400256520ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RebaseToUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase the current branch to the selected branch upstream", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CloneIntoRemote("origin"). EmptyCommit("ensure-master"). EmptyCommit("to-be-added"). // <- this will only exist remotely PushBranchAndSetUpstream("origin", "master"). RenameCurrentBranch("master-local"). HardReset("HEAD~1"). NewBranchFrom("base-branch", "master-local"). EmptyCommit("base-branch-commit"). NewBranch("target"). EmptyCommit("target-commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Lines( Contains("target-commit"), Contains("base-branch-commit"), Contains("ensure-master"), ) t.Views().Branches(). Focus(). Lines( Contains("target").IsSelected(), Contains("base-branch"), Contains("master-local"), ). SelectNextItem(). Lines( Contains("target"), Contains("base-branch").IsSelected(), Contains("master-local"), ). Press(keys.Branches.SetUpstream). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Upstream options")). Select(Contains("Rebase checked-out branch onto upstream of selected branch")). Tooltip(Contains("Disabled: The selected branch has no upstream (or the upstream is not stored locally)")). Confirm(). Tap(func() { t.ExpectToast(Equals("Disabled: The selected branch has no upstream (or the upstream is not stored locally)")) }). Cancel() }). SelectNextItem(). Lines( Contains("target"), Contains("base-branch"), Contains("master-local").IsSelected(), ). Press(keys.Branches.SetUpstream). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Upstream options")). Select(Contains("Rebase checked-out branch onto origin/master...")). Confirm() t.ExpectPopup().Menu(). Title(Equals("Rebase 'target'")). Select(Contains("Simple rebase")). Confirm() }) t.Views().Commits().Lines( Contains("target-commit"), Contains("base-branch-commit"), Contains("to-be-added"), Contains("ensure-master"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/rename.go000066400000000000000000000015341500612110400232340ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Rename = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rename a branch, replacing spaces in the name with dashes", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("master"), ). Press(keys.Branches.RenameBranch). Tap(func() { t.ExpectPopup().Prompt(). Title(Contains("Enter new branch name")). InitialText(Equals("master")). Clear(). Type("new branch name"). Confirm() }). Lines( Contains("new-branch-name"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/reset.go000066400000000000000000000023501500612110400231040ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Reset = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Hard reset to another branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("current-branch") shell.EmptyCommit("root commit") shell.NewBranch("other-branch") shell.EmptyCommit("other-branch commit") shell.Checkout("current-branch") shell.EmptyCommit("current-branch commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Lines( Contains("current-branch commit"), Contains("root commit"), ) t.Views().Branches(). Focus(). Lines( Contains("current-branch").IsSelected(), Contains("other-branch"), ). SelectNextItem(). Press(keys.Commits.ViewResetOptions) t.ExpectPopup().Menu(). Title(Contains("Reset to other-branch")). Select(Contains("Hard reset")). Confirm() // assert that we now have the expected commits in the commit panel t.Views().Commits(). Lines( Contains("other-branch commit"), Contains("root commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/reset_to_upstream.go000066400000000000000000000056631500612110400255400ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ResetToUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Hard reset the current branch to the selected branch upstream", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CloneIntoRemote("origin"). NewBranch("hard-branch"). EmptyCommit("hard commit"). PushBranchAndSetUpstream("origin", "hard-branch"). NewBranch("soft-branch"). EmptyCommit("soft commit"). PushBranchAndSetUpstream("origin", "soft-branch"). RenameCurrentBranch("soft-branch-local"). NewBranch("base"). EmptyCommit("base-branch commit"). CreateFile("file-1", "content"). GitAdd("file-1"). Commit("commit with file"). CreateFile("file-2", "content"). GitAdd("file-2") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // soft reset t.Views().Branches(). Focus(). Lines( Contains("base").IsSelected(), Contains("soft-branch-local"), Contains("hard-branch"), ). Press(keys.Branches.SetUpstream). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Upstream options")). Select(Contains("Reset checked-out branch onto upstream of selected branch")). Tooltip(Contains("Disabled: The selected branch has no upstream (or the upstream is not stored locally)")). Confirm(). Tap(func() { t.ExpectToast(Equals("Disabled: The selected branch has no upstream (or the upstream is not stored locally)")) }). Cancel() }). SelectNextItem(). Lines( Contains("base"), Contains("soft-branch-local").IsSelected(), Contains("hard-branch"), ). Press(keys.Branches.SetUpstream). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Upstream options")). Select(Contains("Reset checked-out branch onto origin/soft-branch...")). Confirm() t.ExpectPopup().Menu(). Title(Equals("Reset to origin/soft-branch")). Select(Contains("Soft reset")). Confirm() }) t.Views().Commits().Lines( Contains("soft commit"), Contains("hard commit"), ) t.Views().Files().Lines( Equals("▼ /"), Equals(" A file-1"), Equals(" A file-2"), ) // hard reset t.Views().Branches(). Focus(). Lines( Contains("base"), Contains("soft-branch-local").IsSelected(), Contains("hard-branch"), ). NavigateToLine(Contains("hard-branch")). Press(keys.Branches.SetUpstream). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Upstream options")). Select(Contains("Reset checked-out branch onto origin/hard-branch...")). Confirm() t.ExpectPopup().Menu(). Title(Equals("Reset to origin/hard-branch")). Select(Contains("Hard reset")). Confirm() }) t.Views().Commits().Lines(Contains("hard commit")) t.Views().Files().IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/select_commits_of_current_branch.go000066400000000000000000000034721500612110400305450ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SelectCommitsOfCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Select all commits of the current branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("master 01") shell.EmptyCommit("master 02") shell.NewBranch("branch1") shell.CreateNCommits(2) shell.NewBranchFrom("branch2", "master") shell.CreateNCommits(3) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), Contains("master 02"), Contains("master 01"), ). Press(keys.Commits.SelectCommitsOfCurrentBranch). Lines( Contains("commit 03").IsSelected(), Contains("commit 02").IsSelected(), Contains("commit 01").IsSelected(), Contains("master 02"), Contains("master 01"), ). PressEscape(). Lines( Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), Contains("master 02"), Contains("master 01"), ) t.Views().Branches(). Focus(). Lines( Contains("branch2").IsSelected(), Contains("branch1"), Contains("master"), ). SelectNextItem(). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("commit 02").IsSelected(), Contains("commit 01"), Contains("master 02"), Contains("master 01"), ). Press(keys.Commits.SelectCommitsOfCurrentBranch). Lines( Contains("commit 02").IsSelected(), Contains("commit 01").IsSelected(), Contains("master 02"), Contains("master 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/set_upstream.go000066400000000000000000000023321500612110400244750ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Set the upstream of a branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneIntoRemote("origin") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Press(keys.Universal.NextScreenMode). // we need to enlargen the window to see the upstream Lines( Contains("master").DoesNotContain("origin master").IsSelected(), ). Press(keys.Branches.SetUpstream). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Upstream options")). Select(Contains(" Set upstream of selected branch")). // using leading space to disambiguate from the 'reset' option Confirm() t.ExpectPopup().Prompt(). Title(Equals("Enter upstream as ' '")). SuggestionLines(Equals("origin master")). ConfirmFirstSuggestion() }). Lines( Contains("master").Contains("origin master").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/shared.go000066400000000000000000000011121500612110400232230ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/samber/lo" ) func checkRemoteBranches(t *TestDriver, keys config.KeybindingConfig, remoteName string, expectedBranches []string) { t.Views().Remotes(). Focus(). NavigateToLine(Contains(remoteName)). PressEnter() t.Views(). RemoteBranches(). Lines( lo.Map(expectedBranches, func(branch string, _ int) *TextMatcher { return Equals(branch) })..., ). Press(keys.Universal.Return) t.Views(). Branches(). Focus() } lazygit-0.50.0+ds1/pkg/integration/tests/branch/show_divergence_from_base_branch.go000066400000000000000000000025601500612110400304720ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ShowDivergenceFromBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Show divergence from base branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.ShowDivergenceFromBaseBranch = "arrowAndNumber" }, SetupRepo: func(shell *Shell) { shell. EmptyCommit("master 1"). EmptyCommit("master 2"). EmptyCommit("master 3"). NewBranchFrom("feature", "master^"). EmptyCommit("feature 1"). EmptyCommit("feature 2") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("feature ↓1").IsSelected(), Contains("master"), ). Press(keys.Branches.SetUpstream) t.ExpectPopup().Menu().Title(Contains("Upstream")). Select(Contains("View divergence from base branch (master)")).Confirm() t.Views().SubCommits(). IsFocused(). Title(Contains("Commits (feature <-> master)")). Lines( DoesNotContainAnyOf("↓", "↑").Contains("--- Remote ---"), Contains("↓").Contains("master 3"), DoesNotContainAnyOf("↓", "↑").Contains("--- Local ---"), Contains("↑").Contains("feature 2"), Contains("↑").Contains("feature 1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/show_divergence_from_upstream.go000066400000000000000000000027531500612110400301070ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ShowDivergenceFromUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Show divergence from upstream", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "content1") shell.Commit("one") shell.UpdateFileAndAdd("file", "content2") shell.Commit("two") shell.CreateFileAndAdd("file3", "content3") shell.Commit("three") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.HardReset("HEAD^^") shell.CreateFileAndAdd("file4", "content4") shell.Commit("four") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("four"), Contains("one"), ) t.Views().Branches(). Focus(). Lines(Contains("master")). Press(keys.Branches.SetUpstream) t.ExpectPopup().Menu().Title(Contains("Upstream")).Select(Contains("View divergence from upstream")).Confirm() t.Views().SubCommits(). IsFocused(). Title(Contains("Commits (master <-> origin/master)")). Lines( DoesNotContainAnyOf("↓", "↑").Contains("--- Remote ---"), Contains("↓").Contains("three"), Contains("↓").Contains("two"), DoesNotContainAnyOf("↓", "↑").Contains("--- Local ---"), Contains("↑").Contains("four"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go000066400000000000000000000020001500612110400327570ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ShowDivergenceFromUpstreamNoDivergence = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Show divergence from upstream when the divergence view is empty", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("commit1") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines(Contains("master")). Press(keys.Branches.SetUpstream) t.ExpectPopup().Menu().Title(Contains("Upstream")).Select(Contains("View divergence from upstream")).Confirm() t.Views().SubCommits(). IsFocused(). Title(Contains("Commits (master <-> origin/master)")). Lines( Contains("--- Remote ---"), Contains("--- Local ---"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/sort_local_branches.go000066400000000000000000000037441500612110400260000ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SortLocalBranches = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Sort local branches by recency, date or alphabetically", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("commit"). NewBranch("first"). EmptyCommitWithDate("commit", "2023-04-07 10:00:00"). NewBranch("second"). EmptyCommitWithDate("commit", "2023-04-07 12:00:00"). NewBranch("third"). EmptyCommitWithDate("commit", "2023-04-07 11:00:00"). Checkout("master") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // sorted by recency by default t.Views().Branches(). Focus(). Lines( Contains("master").IsSelected(), Contains("third"), Contains("second"), Contains("first"), ). SelectNextItem() // to test that the selection jumps back to the top when sorting t.Views().Branches(). Press(keys.Branches.SortOrder) t.ExpectPopup().Menu().Title(Equals("Sort order")). Lines( Contains("r (•) Recency").IsSelected(), Contains("a ( ) Alphabetical"), Contains("d ( ) Date"), Contains(" Cancel"), ). Select(Contains("-committerdate")). Confirm() t.Views().Branches(). IsFocused(). Lines( Contains("master").IsSelected(), Contains("second"), Contains("third"), Contains("first"), ) t.Views().Branches(). Press(keys.Branches.SortOrder) t.ExpectPopup().Menu().Title(Equals("Sort order")). Lines( Contains("r ( ) Recency").IsSelected(), Contains("a ( ) Alphabetical"), Contains("d (•) Date"), Contains(" Cancel"), ). Select(Contains("refname")). Confirm() t.Views().Branches(). IsFocused(). Lines( Contains("master").IsSelected(), Contains("first"), Contains("second"), Contains("third"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/sort_remote_branches.go000066400000000000000000000030661500612110400261760ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SortRemoteBranches = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Sort remote branches alphabetically or by date", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("first") shell.EmptyCommitWithDate("commit", "2023-04-07 10:00:00") shell.NewBranch("second") shell.EmptyCommitWithDate("commit", "2023-04-07 12:00:00") shell.NewBranch("third") shell.EmptyCommitWithDate("commit", "2023-04-07 11:00:00") shell.CloneIntoRemote("origin") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Remotes(). Focus(). Lines( Contains("origin").IsSelected(), ). PressEnter() // sorted alphabetically by default t.Views().RemoteBranches(). IsFocused(). Lines( Contains("first").IsSelected(), Contains("second"), Contains("third"), ). SelectNextItem() // to test that the selection jumps back to the first when sorting t.Views().RemoteBranches(). Press(keys.Branches.SortOrder) t.ExpectPopup().Menu().Title(Equals("Sort order")). Lines( Contains("a (•) Alphabetical").IsSelected(), Contains("d ( ) Date"), Contains(" Cancel"), ). Select(Contains("-committerdate")). Confirm() t.Views().RemoteBranches(). IsFocused(). Lines( Contains("second").IsSelected(), Contains("third"), Contains("first"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/squash_merge.go000066400000000000000000000032011500612110400244410ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SquashMerge = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Squash merge a branch both with and without committing", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("original-branch"). EmptyCommit("one"). NewBranch("change-worktree-branch"). CreateFileAndAdd("work", "content"). Commit("work"). Checkout("original-branch"). NewBranch("change-commit-branch"). CreateFileAndAdd("file", "content"). Commit("file"). Checkout("original-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().TopLines( Contains("one"), ) t.Views().Branches(). Focus(). Lines( Contains("original-branch").IsSelected(), Contains("change-commit-branch"), Contains("change-worktree-branch"), ). SelectNextItem(). Press(keys.Branches.MergeIntoCurrentBranch) t.ExpectPopup().Menu(). Title(Equals("Merge")). Select(Contains("Squash merge and commit")). Confirm() t.Views().Commits().TopLines( Contains("Squash merge change-commit-branch into original-branch"), Contains("one"), ) t.Views().Branches(). Focus(). NavigateToLine(Contains("change-worktree-branch")). Press(keys.Branches.MergeIntoCurrentBranch) t.ExpectPopup().Menu(). Title(Equals("Merge")). Select(Contains("Squash merge and leave uncommitted")). Confirm() t.Views().Files().Focus().Lines( Contains("work"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/suggestions.go000066400000000000000000000021401500612110400243310ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Suggestions = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Checking out a branch with name suggestions", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("my commit message"). NewBranch("new-branch"). NewBranch("new-branch-2"). NewBranch("new-branch-3"). NewBranch("branch-to-checkout"). NewBranch("other-new-branch-2"). NewBranch("other-new-branch-3") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Press(keys.Branches.CheckoutBranchByName) // we expect the first suggestion to be the branch we want because it most // closely matches what we typed in t.ExpectPopup().Prompt(). Title(Equals("Branch name:")). Type("branch-to"). SuggestionTopLines(Contains("branch-to-checkout")). ConfirmFirstSuggestion() t.Git().CurrentBranchName("branch-to-checkout") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/branch/unset_upstream.go000066400000000000000000000035071500612110400250450ustar00rootroot00000000000000package branch import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var UnsetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Unset upstream of selected branch, both when it exists and when it doesn't", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("one"). NewBranch("branch_to_remove"). Checkout("master"). CloneIntoRemote("origin"). SetBranchUpstream("master", "origin/master"). SetBranchUpstream("branch_to_remove", "origin/branch_to_remove"). // to get the "(upstream gone)" branch status RunCommand([]string{"git", "push", "origin", "--delete", "branch_to_remove"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Press(keys.Universal.NextScreenMode). // we need to enlargen the window to see the upstream SelectedLines( Contains("master").Contains("origin master"), ). Press(keys.Branches.SetUpstream). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Upstream options")). Select(Contains("Unset upstream of selected branch")). Confirm() }). SelectedLines( Contains("master").DoesNotContain("origin master"), ) t.Views().Branches(). Focus(). SelectNextItem(). SelectedLines( Contains("branch_to_remove").Contains("origin branch_to_remove").Contains("upstream gone"), ). Press(keys.Branches.SetUpstream). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Upstream options")). Select(Contains("Unset upstream of selected branch")). Confirm() }). SelectedLines( Contains("branch_to_remove").DoesNotContain("origin branch_to_remove").DoesNotContain("upstream gone"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/cherry_pick/000077500000000000000000000000001500612110400225005ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/cherry_pick/cherry_pick.go000066400000000000000000000054131500612110400253340ustar00rootroot00000000000000package cherry_pick import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cherry pick commits from the subcommits view, without conflicts", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("base"). NewBranch("first-branch"). NewBranch("second-branch"). Checkout("first-branch"). EmptyCommit("one"). EmptyCommit("two"). Checkout("second-branch"). EmptyCommit("three"). EmptyCommit("four"). Checkout("first-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("first-branch"), Contains("second-branch"), Contains("master"), ). SelectNextItem(). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("base"), ). // copy commits 'four' and 'three' Press(keys.Commits.CherryPickCopy). Tap(func() { t.Views().Information().Content(Contains("1 commit copied")) }). SelectNextItem(). Press(keys.Commits.CherryPickCopy) t.Views().Information().Content(Contains("2 commits copied")) t.Views().Commits(). Focus(). Lines( Contains("two").IsSelected(), Contains("one"), Contains("base"), ). Press(keys.Commits.PasteCommits). Tap(func() { // cherry-picked commits will be deleted after confirmation t.Views().Information().Content(Contains("2 commits copied")) }). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() }). Tap(func() { t.Views().Information().Content(DoesNotContain("commits copied")) }). Lines( Contains("four"), Contains("three"), Contains("two"), Contains("one"), Contains("base"), ) // Even though the cherry-picking mode has been reset, it's still possible to paste the copied commits again: t.Views().Branches(). Focus(). NavigateToLine(Contains("master")). PressPrimaryAction() t.Views().Commits(). Focus(). Lines( Contains("base").IsSelected(), ). Press(keys.Commits.PasteCommits). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() }). Tap(func() { t.Views().Information().Content(DoesNotContain("commits copied")) }). Lines( Contains("four"), Contains("three"), Contains("base"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go000066400000000000000000000053101500612110400273740ustar00rootroot00000000000000package cherry_pick import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var CherryPickConflicts = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cherry pick commits from the subcommits view, with conflicts", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.MergeConflictsSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("first-change-branch"), Contains("second-change-branch"), Contains("original-branch"), ). SelectNextItem(). PressEnter() t.Views().SubCommits(). IsFocused(). TopLines( Contains("second-change-branch unrelated change"), Contains("second change"), ). Press(keys.Commits.CherryPickCopy). Tap(func() { t.Views().Information().Content(Contains("1 commit copied")) }). SelectNextItem(). Press(keys.Commits.CherryPickCopy) t.Views().Information().Content(Contains("2 commits copied")) t.Views().Commits(). Focus(). TopLines( Contains("first change"), ). Press(keys.Commits.PasteCommits) t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() t.Common().AcknowledgeConflicts() // cherry pick selection is not cleared when there are conflicts, so that the user // is able to abort and try again without having to re-copy the commits t.Views().Information().Content(Contains("2 commits copied")) t.Views().Files(). IsFocused(). SelectedLine(Contains("file")). PressEnter() t.Views().MergeConflicts(). IsFocused(). // picking 'Second change' SelectNextItem(). PressPrimaryAction() t.Common().ContinueOnConflictsResolved("cherry-pick") t.Views().Files().IsEmpty() t.Views().Commits(). Focus(). TopLines( Contains("second-change-branch unrelated change"), Contains("second change"), Contains("first change"), ). SelectNextItem(). Tap(func() { // because we picked 'Second change' when resolving the conflict, // we now see this commit as having replaced First Change with Second Change, // as opposed to replacing 'Original' with 'Second change' t.Views().Main(). Content(Contains("-First Change")). Content(Contains("+Second Change")) t.Views().Information().Content(Contains("2 commits copied")) }). PressEscape(). Tap(func() { t.Views().Information().Content(DoesNotContain("commits copied")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go000066400000000000000000000045261500612110400302310ustar00rootroot00000000000000package cherry_pick import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CherryPickDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cherry pick commits from the subcommits view during a rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. EmptyCommit("base"). NewBranch("first-branch"). NewBranch("second-branch"). Checkout("first-branch"). EmptyCommit("one"). EmptyCommit("two"). Checkout("second-branch"). EmptyCommit("three"). EmptyCommit("four"). Checkout("first-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("first-branch"), Contains("second-branch"), Contains("master"), ). SelectNextItem(). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("base"), ). // copy commit 'three' SelectNextItem(). Press(keys.Commits.CherryPickCopy) t.Views().Information().Content(Contains("1 commit copied")) t.Views().Commits(). Focus(). Lines( Contains("CI two").IsSelected(), Contains("CI one"), Contains("CI base"), ). SelectNextItem(). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick CI two"), Contains("--- Commits ---"), Contains(" CI one").IsSelected(), Contains(" CI base"), ). Press(keys.Commits.PasteCommits). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). Confirm() }). Tap(func() { t.Views().Information().Content(DoesNotContain("commit copied")) }). Lines( Contains("--- Pending rebase todos ---"), Contains("pick CI two"), Contains("--- Commits ---"), Contains(" CI three"), Contains(" CI one"), Contains(" CI base"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("CI two"), Contains("CI three"), Contains("CI one"), Contains("CI base"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/cherry_pick/cherry_pick_merge.go000066400000000000000000000040251500612110400265110ustar00rootroot00000000000000package cherry_pick import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CherryPickMerge = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cherry pick a merge commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("base"). NewBranch("first-branch"). NewBranch("second-branch"). Checkout("first-branch"). Checkout("second-branch"). CreateFileAndAdd("file1.txt", "content"). Commit("one"). CreateFileAndAdd("file2.txt", "content"). Commit("two"). Checkout("master"). Merge("second-branch"). Checkout("first-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("first-branch"), Contains("master"), Contains("second-branch"), ). SelectNextItem(). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("⏣─╮ Merge branch 'second-branch'").IsSelected(), Contains("│ ◯ two"), Contains("│ ◯ one"), Contains("◯ ╯ base"), ). // copy the merge commit Press(keys.Commits.CherryPickCopy) t.Views().Information().Content(Contains("1 commit copied")) t.Views().Commits(). Focus(). Lines( Contains("base").IsSelected(), ). Press(keys.Commits.PasteCommits). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). Confirm() }). Tap(func() { t.Views().Information().Content(DoesNotContain("commit copied")) }). Lines( Contains("Merge branch 'second-branch'").IsSelected(), Contains("base"), ) t.Views().Main().ContainsLines( Contains("Merge branch 'second-branch'"), Contains("---"), Contains("file1.txt | 1 +"), Contains("file2.txt | 1 +"), Contains("2 files changed, 2 insertions(+)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/cherry_pick/cherry_pick_range.go000066400000000000000000000036651500612110400265170ustar00rootroot00000000000000package cherry_pick import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CherryPickRange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cherry pick range of commits from the subcommits view, without conflicts", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("base"). NewBranch("first-branch"). NewBranch("second-branch"). Checkout("first-branch"). EmptyCommit("one"). EmptyCommit("two"). Checkout("second-branch"). EmptyCommit("three"). EmptyCommit("four"). Checkout("first-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("first-branch"), Contains("second-branch"), Contains("master"), ). SelectNextItem(). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("base"), ). // copy commits 'four' and 'three' Press(keys.Universal.RangeSelectDown). Lines( Contains("four").IsSelected(), Contains("three").IsSelected(), Contains("base"), ). Press(keys.Commits.CherryPickCopy) t.Views().Information().Content(Contains("2 commits copied")) t.Views().Commits(). Focus(). Lines( Contains("two").IsSelected(), Contains("one"), Contains("base"), ). Press(keys.Commits.PasteCommits). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() }). Tap(func() { t.Views().Information().Content(DoesNotContain("commits copied")) }). Lines( Contains("four"), Contains("three"), Contains("two"), Contains("one"), Contains("base"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/000077500000000000000000000000001500612110400214665ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/commit/add_co_author.go000066400000000000000000000020411500612110400246050ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AddCoAuthor = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add co-author on a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("initial commit").IsSelected(), ). Press(keys.Commits.ResetCommitAuthor). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Amend commit attribute")). Select(Contains("Add co-author")). Confirm() t.ExpectPopup().Prompt(). Title(Contains("Add co-author")). Type("John Smith "). Confirm() }) t.Views().Main().ContainsLines( Equals(" initial commit"), Equals(" "), Equals(" Co-authored-by: John Smith "), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/add_co_author_range.go000066400000000000000000000051331500612110400257660ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AddCoAuthorRange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add co-author on a range of commits", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("fourth commit") shell.EmptyCommit("third commit") shell.EmptyCommit("second commit") shell.EmptyCommit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit").IsSelected(), Contains("second commit"), Contains("third commit"), Contains("fourth commit"), ). SelectNextItem(). Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). Lines( Contains("first commit"), Contains("second commit").IsSelected(), Contains("third commit").IsSelected(), Contains("fourth commit"), ). Press(keys.Commits.ResetCommitAuthor). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Amend commit attribute")). Select(Contains("Add co-author")). Confirm() t.ExpectPopup().Prompt(). Title(Contains("Add co-author")). Type("John Smith "). Confirm() }). // exit range selection mode PressEscape(). SelectNextItem() t.Views().Main().Content( Contains("fourth commit"). DoesNotContain("Co-authored-by: John Smith "), ) t.Views().Commits(). IsFocused(). SelectPreviousItem(). Lines( Contains("first commit"), Contains("second commit"), Contains("third commit").IsSelected(), Contains("fourth commit"), ) t.Views().Main().ContainsLines( Equals(" third commit"), Equals(" "), Equals(" Co-authored-by: John Smith "), ) t.Views().Commits(). IsFocused(). SelectPreviousItem(). Lines( Contains("first commit"), Contains("second commit").IsSelected(), Contains("third commit"), Contains("fourth commit"), ) t.Views().Main().ContainsLines( Equals(" second commit"), Equals(" "), Equals(" Co-authored-by: John Smith "), ) t.Views().Commits(). IsFocused(). SelectPreviousItem(). Lines( Contains("first commit").IsSelected(), Contains("second commit"), Contains("third commit"), Contains("fourth commit"), ) t.Views().Main().Content( Contains("first commit"). DoesNotContain("Co-authored-by: John Smith "), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/add_co_author_while_committing.go000066400000000000000000000027551500612110400302430ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AddCoAuthorWhileCommitting = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add co-author while typing the commit message", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFile("file", "file content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). PressPrimaryAction(). // stage file Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Type("Subject"). SwitchToDescription(). Type("Here's my message."). AddCoAuthor("John Doe "). Content(Equals("Here's my message.\n\nCo-authored-by: John Doe ")). AddCoAuthor("Jane Smith "). // Second co-author doesn't add a blank line: Content(Equals("Here's my message.\n\nCo-authored-by: John Doe \nCo-authored-by: Jane Smith ")). SwitchToSummary(). Confirm() t.Views().Commits(). Lines( Contains("Subject"), ). Focus(). Tap(func() { t.Views().Main().ContainsLines( Equals(" Subject"), Equals(" "), Equals(" Here's my message."), Equals(" "), Equals(" Co-authored-by: John Doe "), Equals(" Co-authored-by: Jane Smith "), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/amend.go000066400000000000000000000020601500612110400230770ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Amend = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends the last commit from the files panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "myfile content\n") shell.Commit("first commit") shell.UpdateFileAndAdd("myfile", "myfile content\nmore content\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("first commit"), ) t.Views().Files(). Focus(). Press(keys.Commits.AmendToCommit) t.ExpectPopup().Confirmation().Title( Equals("Amend last commit")). Content(Contains("Are you sure you want to amend last commit?")). Confirm() t.Views().Commits(). Focus(). Lines( Contains("first commit"), ) t.Views().Main().Content(Contains("+myfile content").Contains("+more content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go000066400000000000000000000023251500612110400321740ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendWhenThereAreConflictsAndAmend = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends the last commit from the files panel while a rebase is stopped due to conflicts, and amends the commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { setupForAmendTests(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { doTheRebaseForAmendTests(t, keys) t.Views().Files(). Press(keys.Commits.AmendToCommit) t.ExpectPopup().Menu(). Title(Equals("Amend commit")). Select(Equals("Yes, amend previous commit")). Confirm() t.Views().Files().IsEmpty() t.Views().Commits(). Focus(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit three"), Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), Contains("--- Commits ---"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), ) checkCommitContainsChange(t, "commit two", "+branch") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go000066400000000000000000000023221500612110400323320ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendWhenThereAreConflictsAndCancel = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends the last commit from the files panel while a rebase is stopped due to conflicts, and cancels the confirmation", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { setupForAmendTests(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { doTheRebaseForAmendTests(t, keys) t.Views().Files(). Press(keys.Commits.AmendToCommit) t.ExpectPopup().Menu(). Title(Equals("Amend commit")). Select(Equals("Cancel")). Confirm() // Check that nothing happened: t.Views().Files(). Lines( Contains("M file1"), ) t.Views().Commits(). Focus(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit three"), Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), Contains("--- Commits ---"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_continue.go000066400000000000000000000021371500612110400327350ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendWhenThereAreConflictsAndContinue = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends the last commit from the files panel while a rebase is stopped due to conflicts, and continues the rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { setupForAmendTests(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { doTheRebaseForAmendTests(t, keys) t.Views().Files(). Press(keys.Commits.AmendToCommit) t.ExpectPopup().Menu(). Title(Equals("Amend commit")). Select(Equals("No, continue rebase")). Confirm() t.Views().Files().IsEmpty() t.Views().Commits(). Focus(). Lines( Contains("commit three"), Contains("file1 changed in branch"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), ) checkCommitContainsChange(t, "file1 changed in branch", "+branch") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/auto_wrap_message.go000066400000000000000000000036241500612110400255270ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AutoWrapMessage = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit, and test how the commit message body is auto-wrapped", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { // Use a ridiculously small width so that we don't have to use so much test data config.GetUserConfig().Git.Commit.AutoWrapWidth = 20 }, SetupRepo: func(shell *Shell) { shell.CreateFile("file", "file content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). PressPrimaryAction(). // stage file Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Type("subject"). SwitchToDescription(). Type("Lorem ipsum dolor sit amet, consectetur adipiscing elit."). // See how it automatically inserted line feeds to wrap the text: Content(Equals("Lorem ipsum dolor \nsit amet, \nconsectetur \nadipiscing elit.")). SwitchToSummary(). Confirm() t.Views().Commits(). Lines( Contains("subject"), ). Focus(). Tap(func() { t.Views().Main().Content(Contains( "subject\n \n Lorem ipsum dolor\n sit amet,\n consectetur\n adipiscing elit.")) }). Press(keys.Commits.RenameCommit) // Test that when rewording, the hard line breaks are turned back into // soft ones, so that we can insert text at the beginning and have the // paragraph reflow nicely. t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("subject")). SwitchToDescription(). Content(Equals("Lorem ipsum dolor \nsit amet, \nconsectetur \nadipiscing elit.")). GoToBeginning(). Type("More text. "). Content(Equals("More text. Lorem \nipsum dolor sit \namet, consectetur \nadipiscing elit.")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/checkout.go000066400000000000000000000036221500612110400236250ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Checkout = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Checkout a commit as a detached head, or checkout an existing branch at a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.NewBranch("branch1") shell.NewBranch("branch2") shell.EmptyCommit("three") shell.EmptyCommit("four") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). PressPrimaryAction() t.ExpectPopup().Menu(). Title(Contains("Checkout branch or commit")). Lines( MatchesRegexp("Checkout commit [a-f0-9]+ as detached head").IsSelected(), Contains("Checkout branch"), Contains("Cancel"), ). Select(Contains("Checkout branch")). Tooltip(Contains("Disabled: No branches found at selected commit.")). Select(MatchesRegexp("Checkout commit [a-f0-9]+ as detached head")). Confirm() t.Views().Branches().Lines( Contains("* (HEAD detached at"), Contains("branch2"), Contains("branch1"), Contains("master"), ) t.Views().Commits(). NavigateToLine(Contains("two")). PressPrimaryAction() t.ExpectPopup().Menu(). Title(Contains("Checkout branch or commit")). Lines( MatchesRegexp("Checkout commit [a-f0-9]+ as detached head").IsSelected(), Contains("Checkout branch 'branch1'"), Contains("Checkout branch 'master'"), Contains("Cancel"), ). Select(Contains("Checkout branch 'master'")). Confirm() t.Views().Branches().Lines( Contains("master"), Contains("branch2"), Contains("branch1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/checkout_file_from_commit.go000066400000000000000000000024421500612110400272160ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CheckoutFileFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Checkout a file from a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file.txt", "one\n") shell.Commit("one") shell.CreateFileAndAdd("file.txt", "two\n") shell.Commit("two") shell.CreateFileAndAdd("file.txt", "three\n") shell.Commit("three") shell.CreateFileAndAdd("file.txt", "four\n") shell.Commit("four") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). NavigateToLine(Contains("three")). Tap(func() { t.Views().Main().ContainsLines( Contains("-two"), Contains("+three"), ) }). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("M file.txt"), ). Press(keys.CommitFiles.CheckoutCommitFile) t.Views().Files(). Lines( Equals("M file.txt"), ) t.FileSystem().FileContent("file.txt", Equals("three\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/checkout_file_from_range_selection_of_commits.go000066400000000000000000000025611500612110400333100ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CheckoutFileFromRangeSelectionOfCommits = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Checkout a file from a range selection of commits", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file.txt", "one\n") shell.Commit("one") shell.CreateFileAndAdd("file.txt", "two\n") shell.Commit("two") shell.CreateFileAndAdd("file.txt", "three\n") shell.Commit("three") shell.CreateFileAndAdd("file.txt", "four\n") shell.Commit("four") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). NavigateToLine(Contains("three")). Press(keys.Universal.RangeSelectDown). Tap(func() { t.Views().Main().ContainsLines( Contains("-one"), Contains("+three"), ) }). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("M file.txt"), ). Press(keys.CommitFiles.CheckoutCommitFile) t.Views().Files(). Lines( Equals("M file.txt"), ) t.FileSystem().FileContent("file.txt", Equals("three\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit.go000066400000000000000000000027171500612110400233140ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Commit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Staging a couple files and committing", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile("myfile", "myfile content") shell.CreateFile("myfile2", "myfile2 content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ?? myfile"), Equals(" ?? myfile2"), ). SelectNextItem(). PressPrimaryAction(). // stage file Lines( Equals("▼ /"), Equals(" A myfile").IsSelected(), Equals(" ?? myfile2"), ). SelectNextItem(). PressPrimaryAction(). // stage other file Lines( Equals("▼ /"), Equals(" A myfile"), Equals(" A myfile2").IsSelected(), ). Press(keys.Files.CommitChanges) commitMessage := "my commit message" t.ExpectPopup().CommitMessagePanel().Type(commitMessage).Confirm() t.Views().Files(). IsEmpty() t.Views().Commits(). Focus(). Lines( Contains(commitMessage).IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /"), Equals(" A myfile"), Equals(" A myfile2"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_multiline.go000066400000000000000000000017541500612110400253760ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitMultiline = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit with a multi-line commit message", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile("myfile", "myfile content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Type("first line"). SwitchToDescription(). AddNewline(). AddNewline(). Type("fourth line"). SwitchToSummary(). Confirm() t.Views().Commits(). Lines( Contains("first line"), ) t.Views().Commits().Focus() t.Views().Main().Content(MatchesRegexp("first line\n\\s*\n\\s*fourth line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_skip_hooks.go000066400000000000000000000021071500612110400255360ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var blockingHook = `#!/bin/bash # For this test all we need is a hook that always fails exit 1 ` var CommitSkipHooks = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit with skip hook using CommitChangesWithoutHook", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile(".git/hooks/pre-commit", blockingHook) shell.MakeExecutable(".git/hooks/pre-commit") shell.CreateFile("file.txt", "content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { checkBlockingHook(t, keys) t.Views().Files(). IsFocused(). PressPrimaryAction(). Lines( Equals("A file.txt"), ). Press(keys.Files.CommitChangesWithoutHook) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). Type("foo bar"). Confirm() t.Views().Commits().Focus() t.Views().Main().Content(Contains("foo bar")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_switch_to_editor.go000066400000000000000000000034211500612110400267360ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitSwitchToEditor = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit, then switch from built-in commit message panel to editor", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile("file1", "file1 content") shell.CreateFile("file2", "file2 content") // Set an editor that appends a line to the existing message. Since // git adds all this "# Please enter the commit message for your changes" // stuff, this will result in an extra blank line before the added line. shell.SetConfig("core.editor", "sh -c 'echo third line >>.git/COMMIT_EDITMSG'") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ?? file1"), Equals(" ?? file2"), ). SelectNextItem(). PressPrimaryAction(). // stage one of the files Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Type("first line"). SwitchToDescription(). Type("second line"). SwitchToSummary(). SwitchToEditor() t.Views().Commits(). Lines( Contains("first line"), ) t.Views().Commits().Focus() t.Views().Main().Content(MatchesRegexp(`first line\n\s*\n\s*second line\n\s*\n\s*third line`)) // Now check that the preserved commit message was cleared: t.Views().Files(). Focus(). Lines( Equals("?? file2"), ). PressPrimaryAction(). // stage the other file Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_switch_to_editor_skip_hooks.go000066400000000000000000000036051500612110400311730ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitSwitchToEditorSkipHooks = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit, then switch from built-in commit message panel to editor", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile(".git/hooks/pre-commit", blockingHook) shell.MakeExecutable(".git/hooks/pre-commit") shell.CreateFile("file1", "file1 content") shell.CreateFile("file2", "file2 content") // Set an editor that appends a line to the existing message. Since // git adds all this "# Please enter the commit message for your changes" // stuff, this will result in an extra blank line before the added line. shell.SetConfig("core.editor", "sh -c 'echo third line >>.git/COMMIT_EDITMSG'") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() checkBlockingHook(t, keys) t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ?? file1"), Equals(" ?? file2"), ). SelectNextItem(). PressPrimaryAction(). // stage one of the files Press(keys.Files.CommitChangesWithoutHook) t.ExpectPopup().CommitMessagePanel(). Type("first line"). SwitchToDescription(). Type("second line"). SwitchToSummary(). SwitchToEditor() t.Views().Commits(). Lines( Contains("first line"), ) t.Views().Commits().Focus() t.Views().Main().Content(MatchesRegexp(`first line\n\s*\n\s*second line\n\s*\n\s*third line`)) // Now check that the preserved commit message was cleared: t.Views().Files(). Focus(). PressPrimaryAction(). // stage the other file Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_wip_with_prefix.go000066400000000000000000000033571500612110400266040ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitWipWithPrefix = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit with skip hook and config commitPrefix is defined. Prefix is ignored when creating WIP commits.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Git.CommitPrefixes = map[string][]config.CommitPrefixConfig{"repo": {{Pattern: "^\\w+\\/(\\w+-\\w+).*", Replace: "[$1]: "}}} }, SetupRepo: func(shell *Shell) { shell.CreateFile(".git/hooks/pre-commit", blockingHook) shell.MakeExecutable(".git/hooks/pre-commit") shell.NewBranch("feature/TEST-002") shell.CreateFile("test-wip-commit-prefix", "This is foo bar") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() checkBlockingHook(t, keys) t.Views().Files(). IsFocused(). PressPrimaryAction(). Press(keys.Files.CommitChangesWithoutHook) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("WIP")). Type(" foo"). Cancel() t.Views().Files(). IsFocused(). Press(keys.Files.CommitChangesWithoutHook) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("WIP foo")). Type(" bar"). Cancel() t.Views().Files(). IsFocused(). Press(keys.Files.CommitChangesWithoutHook) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("WIP foo bar")). Type(". Added something else"). Confirm() t.Views().Commits().Focus() t.Views().Main().Content(Contains("WIP foo bar. Added something else")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_with_fallthrough_prefix.go000066400000000000000000000031461500612110400303200ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitWithFallthroughPrefix = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit with multiple CommitPrefixConfig", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Git.CommitPrefix = []config.CommitPrefixConfig{ {Pattern: "^doesntmatch-(\\w+).*", Replace: "[BAD $1]: "}, {Pattern: "^\\w+\\/(\\w+-\\w+).*", Replace: "[GOOD $1]: "}, } cfg.GetUserConfig().Git.CommitPrefixes = map[string][]config.CommitPrefixConfig{ "DifferentProject": {{Pattern: "^otherthatdoesn'tmatch-(\\w+).*", Replace: "[BAD $1]: "}}, } }, SetupRepo: func(shell *Shell) { shell.NewBranch("feature/TEST-001") shell.CreateFile("test-commit-prefix", "This is foo bar") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("[GOOD TEST-001]: ")). Type("my commit message"). Cancel() t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("[GOOD TEST-001]: my commit message")). Type(". Added something else"). Confirm() t.Views().Commits().Focus() t.Views().Main().Content(Contains("[GOOD TEST-001]: my commit message. Added something else")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_with_global_prefix.go000066400000000000000000000025221500612110400272360ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitWithGlobalPrefix = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit with defined config commitPrefix", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Git.CommitPrefix = []config.CommitPrefixConfig{{Pattern: "^\\w+\\/(\\w+-\\w+).*", Replace: "[$1]: "}} }, SetupRepo: func(shell *Shell) { shell.NewBranch("feature/TEST-001") shell.CreateFile("test-commit-prefix", "This is foo bar") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("[TEST-001]: ")). Type("my commit message"). Cancel() t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("[TEST-001]: my commit message")). Type(". Added something else"). Confirm() t.Views().Commits().Focus() t.Views().Main().Content(Contains("[TEST-001]: my commit message. Added something else")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_with_non_matching_branch_name.go000066400000000000000000000016641500612110400314100ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitWithNonMatchingBranchName = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit with defined config commitPrefixes", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Git.CommitPrefix = []config.CommitPrefixConfig{{ Pattern: "^\\w+\\/(\\w+-\\w+).*", Replace: "[$1]: ", }} }, SetupRepo: func(shell *Shell) { shell.NewBranch("branchnomatch") shell.CreateFile("test-commit-prefix", "This is foo bar") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/commit_with_prefix.go000066400000000000000000000031001500612110400257070ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitWithPrefix = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Commit with defined config commitPrefixes", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Git.CommitPrefixes = map[string][]config.CommitPrefixConfig{ "repo": {{ Pattern: `^\w+/(\w+-\w+).*`, Replace: "[$1]: ", }}, } }, SetupRepo: func(shell *Shell) { shell.NewBranch("feature/TEST-001") shell.CreateFile("test-commit-prefix", "This is foo bar") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("[TEST-001]: ")). Cancel() t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("[TEST-001]: ")). Type("my commit message"). Cancel() t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). InitialText(Equals("[TEST-001]: my commit message")). Type(". Added something else"). Confirm() t.Views().Commits().Focus() t.Views().Main().Content(Contains("[TEST-001]: my commit message. Added something else")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/copy_author_to_clipboard.go000066400000000000000000000021231500612110400270700ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // We're emulating the clipboard by writing to a file called clipboard var CopyAuthorToClipboard = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Copy a commit author name to the clipboard", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" }, SetupRepo: func(shell *Shell) { shell.SetAuthor("John Doe", "john@doe.com") shell.EmptyCommit("commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit").IsSelected(), ). Press(keys.Commits.CopyCommitAttributeToClipboard) t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Commit author")). Confirm() t.ExpectToast(Equals("Commit author copied to clipboard")) t.FileSystem().FileContent("clipboard", Equals("John Doe ")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/copy_message_body_to_clipboard.go000066400000000000000000000021601500612110400302300ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // We're emulating the clipboard by writing to a file called clipboard var CopyMessageBodyToClipboard = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Copy a commit message body to the clipboard", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" }, SetupRepo: func(shell *Shell) { shell.EmptyCommitWithBody("My Subject", "My awesome commit message body") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("My Subject").IsSelected(), ). Press(keys.Commits.CopyCommitAttributeToClipboard) t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Commit message body")). Confirm() t.ExpectToast(Equals("Commit message body copied to clipboard")) t.FileSystem().FileContent("clipboard", Equals("My awesome commit message body")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/copy_tag_to_clipboard.go000066400000000000000000000022211500612110400263400ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // We're emulating the clipboard by writing to a file called clipboard var CopyTagToClipboard = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Copy a commit tag to the clipboard", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" }, SetupRepo: func(shell *Shell) { shell.SetAuthor("John Doe", "john@doe.com") shell.EmptyCommit("commit") shell.CreateLightweightTag("tag1", "HEAD") shell.CreateLightweightTag("tag2", "HEAD") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit").IsSelected(), ). Press(keys.Commits.CopyCommitAttributeToClipboard) t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Commit tags")). Confirm() t.ExpectToast(Equals("Commit tags copied to clipboard")) t.FileSystem().FileContent("clipboard", Equals("tag2\ntag1")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/create_amend_commit.go000066400000000000000000000031761500612110400260030ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create an amend commit for an existing commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(3). CreateFileAndAdd("fixup-file", "fixup content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Create fixup commit")). Select(Contains("amend! commit with changes")). Confirm() t.ExpectPopup().CommitMessagePanel(). Content(Equals("commit 02")). Type(" amended").Confirm() }). Lines( Contains("amend! commit 02"), Contains("commit 03"), Contains("commit 02").IsSelected(), Contains("commit 01"), ) if t.Git().Version().IsAtLeast(2, 32, 0) { // Support for auto-squashing "amend!" commits was added in git 2.32.0 t.Views().Commits(). Press(keys.Commits.SquashAboveCommits). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Apply fixup commits")). Select(Contains("Above the selected commit")). Confirm() }). Lines( Contains("commit 03"), Contains("commit 02 amended").IsSelected(), Contains("commit 01"), ) } }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/create_fixup_commit_in_branch_stack.go000066400000000000000000000032621500612110400312360ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CreateFixupCommitInBranchStack = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a fixup commit in a stack of branches, verify that it is created at the end of the branch it belongs to", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("branch1") shell.EmptyCommit("branch1 commit 1") shell.EmptyCommit("branch1 commit 2") shell.EmptyCommit("branch1 commit 3") shell.NewBranch("branch2") shell.EmptyCommit("branch2 commit 1") shell.EmptyCommit("branch2 commit 2") shell.CreateFileAndAdd("fixup-file", "fixup content") shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI ◯ branch2 commit 2"), Contains("CI ◯ branch2 commit 1"), Contains("CI ◯ * branch1 commit 3"), Contains("CI ◯ branch1 commit 2"), Contains("CI ◯ branch1 commit 1"), ). NavigateToLine(Contains("branch1 commit 2")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Create fixup commit")). Select(Contains("fixup! commit")). Confirm() }). Lines( Contains("CI ◯ branch2 commit 2"), Contains("CI ◯ branch2 commit 1"), Contains("CI ◯ * fixup! branch1 commit 2"), Contains("CI ◯ branch1 commit 3"), Contains("CI ◯ branch1 commit 2"), Contains("CI ◯ branch1 commit 1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/create_tag.go000066400000000000000000000017601500612110400241170ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CreateTag = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a new tag on a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("two").IsSelected(), Contains("one"), ). Press(keys.Commits.CreateTag) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). Type("new-tag"). Confirm() t.Views().Commits(). Lines( MatchesRegexp(`new-tag.*two`).IsSelected(), MatchesRegexp(`one`), ) t.Views().Tags(). Focus(). Lines( MatchesRegexp(`new-tag.*two`).IsSelected(), ) t.Git(). TagNamesAt("HEAD", []string{"new-tag"}) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/disable_copy_commit_message_body.go000066400000000000000000000015271500612110400305500ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DisableCopyCommitMessageBody = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Disables copy commit message body when there is no body", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit").IsSelected(), ). Press(keys.Commits.CopyCommitAttributeToClipboard) t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Commit message body")). Confirm() t.ExpectToast(Equals("Disabled: Commit has no message body")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/discard_old_file_changes.go000066400000000000000000000135041500612110400267560ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardOldFileChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discarding a range of files from an old commit.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("dir1/d1_file0", "file0\n") shell.CreateFileAndAdd("dir1/subd1/subfile0", "file1\n") shell.CreateFileAndAdd("dir2/d2_file1", "d2f1 content\n") shell.CreateFileAndAdd("dir2/d2_file2", "d2f4 content\n") shell.Commit("remove one file from this commit") shell.UpdateFileAndAdd("dir2/d2_file1", "d2f1 content\nsecond line\n") shell.DeleteFileAndAdd("dir2/d2_file2") shell.CreateFileAndAdd("dir2/d2_file3", "d2f3 content\n") shell.CreateFileAndAdd("dir2/d2_file4", "d2f2 content\n") shell.Commit("remove four files from this commit") shell.CreateFileAndAdd("dir1/fileToRemove", "file to remove content\n") shell.CreateFileAndAdd("dir1/multiLineFile", "this file has\ncontent on\nthree lines\n") shell.CreateFileAndAdd("dir1/subd1/file2ToRemove", "file2 to remove content\n") shell.Commit("remove changes in multiple dirs from this commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("remove changes in multiple dirs from this commit").IsSelected(), Contains("remove four files from this commit"), Contains("remove one file from this commit"), ). NavigateToLine(Contains("remove one file from this commit")). PressEnter() // Check removing a single file from an old commit t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir1"), Equals(" ▼ subd1"), Equals(" A subfile0"), Equals(" A d1_file0"), Equals(" ▼ dir2"), Equals(" A d2_file1"), Equals(" A d2_file2"), ). NavigateToLine(Contains("d1_file0")). Press(keys.Universal.Remove) t.ExpectPopup().Confirmation(). Title(Equals("Discard file changes")). Content(Equals("Are you sure you want to remove changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\nNote: This will also reset any active custom patches.")). Confirm() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /"), Equals(" ▼ dir1/subd1"), Equals(" A subfile0"), Equals(" ▼ dir2"), Equals(" A d2_file1").IsSelected(), Equals(" A d2_file2"), ). PressEscape() // Check removing 4 files in the same directory t.Views().Commits(). Focus(). Lines( Contains("remove changes in multiple dirs from this commit"), Contains("remove four files from this commit"), Contains("remove one file from this commit").IsSelected(), ). NavigateToLine(Contains("remove four files from this commit")). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ dir2").IsSelected(), Equals(" M d2_file1"), Equals(" D d2_file2"), Equals(" A d2_file3"), Equals(" A d2_file4"), ). NavigateToLine(Contains("d2_file1")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("d2_file4")). Press(keys.Universal.Remove) t.ExpectPopup().Confirmation(). Title(Equals("Discard file changes")). Content(Equals("Are you sure you want to remove changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\nNote: This will also reset any active custom patches.")). Confirm() t.Views().CommitFiles(). IsFocused(). Lines( Contains("(none)"), ). PressEscape() // Check removing multiple files from 2 directories w/ a custom patch. // This checks node selection logic & if the custom patch is getting reset. t.Views().Commits(). IsFocused(). Lines( Contains("remove changes in multiple dirs from this commit"), Contains("remove four files from this commit").IsSelected(), Contains("remove one file from this commit"), ). NavigateToLine(Contains("remove changes in multiple dirs from this commit")). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ dir1").IsSelected(), Equals(" ▼ subd1"), Equals(" A file2ToRemove"), Equals(" A fileToRemove"), Equals(" A multiLineFile"), ). NavigateToLine(Contains("multiLineFile")). PressEnter() t.Views().PatchBuilding(). IsFocused(). SelectedLine( Contains("+this file has"), ). PressPrimaryAction(). PressEscape() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ dir1"), Equals(" ▼ subd1"), Equals(" A file2ToRemove"), Equals(" A fileToRemove"), Equals(" ◐ multiLineFile").IsSelected(), ). NavigateToLine(Contains("dir1")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("subd1")). Press(keys.Universal.Remove) t.ExpectPopup().Confirmation(). Title(Equals("Discard file changes")). Content(Equals("Are you sure you want to remove changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\nNote: This will also reset any active custom patches.")). Confirm() // "Building patch" will still be in this view if the patch isn't reset properly t.Views().Information().Content(DoesNotContain("Building patch")) t.Views().CommitFiles(). IsFocused(). Lines( Contains("(none)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/fail_hooks_then_commit_no_hooks.go000066400000000000000000000024751500612110400304300ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FailHooksThenCommitNoHooks = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that commit message can be reused in commit without hook after failing commit with hooks", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFile(".git/hooks/pre-commit", blockingHook) shell.MakeExecutable(".git/hooks/pre-commit") shell.CreateFileAndAdd("one", "one") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("one"), ). Press(keys.Files.CommitChanges). Tap(func() { t.ExpectPopup().CommitMessagePanel().Type("my message").Confirm() t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("Git command failed")).Confirm() }). Press(keys.Files.CommitChangesWithoutHook). Tap(func() { t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("my message")). // it remembered the commit message Confirm() t.Views().Commits(). Lines( Contains("my message"), ) }) t.Views().Commits().Focus() t.Views().Main().Content(Contains("my message")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/find_base_commit_for_fixup.go000066400000000000000000000040411500612110400273570ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FindBaseCommitForFixup = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Finds the base commit to create a fixup for", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch"). EmptyCommit("1st commit"). CreateFileAndAdd("file1", "file1 content\n"). Commit("2nd commit"). CreateFileAndAdd("file2", "file2 content\n"). Commit("3rd commit"). UpdateFile("file1", "file1 changed content"). UpdateFile("file2", "file2 changed content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("3rd commit"), Contains("2nd commit"), Contains("1st commit"), ) // Two changes from different commits: this fails t.Views().Files(). Focus(). Press(keys.Files.FindBaseCommitForFixup) t.ExpectPopup().Alert(). Title(Equals("Error")). Content( Contains("Multiple base commits found"). Contains("2nd commit"). Contains("3rd commit"), ). Confirm() // Stage only one of the files: this succeeds t.Views().Files(). IsFocused(). NavigateToLine(Contains("file1")). PressPrimaryAction(). Press(keys.Files.FindBaseCommitForFixup) t.Views().Commits(). IsFocused(). Lines( Contains("3rd commit"), Contains("2nd commit").IsSelected(), Contains("1st commit"), ). Press(keys.Commits.AmendToCommit) t.ExpectPopup().Confirmation(). Title(Equals("Amend commit")). Content(Contains("Are you sure you want to amend this commit with your staged files?")). Confirm() // Now only the other file is modified (and unstaged); this works now t.Views().Files(). Focus(). Press(keys.Files.FindBaseCommitForFixup) t.Views().Commits(). IsFocused(). Lines( Contains("3rd commit").IsSelected(), Contains("2nd commit"), Contains("1st commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/find_base_commit_for_fixup_disregard_main_branch.go000066400000000000000000000024431500612110400337300ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FindBaseCommitForFixupDisregardMainBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Finds the base commit to create a fixup for, disregarding changes to a commit that is already on master", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("1st commit"). CreateFileAndAdd("file1", "file1 content\n"). Commit("2nd commit"). NewBranch("mybranch"). CreateFileAndAdd("file2", "file2 content\n"). Commit("3rd commit"). EmptyCommit("4th commit"). UpdateFile("file1", "file1 changed content"). UpdateFile("file2", "file2 changed content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("4th commit").IsSelected(), Contains("3rd commit"), Contains("2nd commit"), Contains("1st commit"), ) t.Views().Files(). Focus(). Press(keys.Files.FindBaseCommitForFixup) t.Views().Commits(). IsFocused(). Lines( Contains("4th commit"), Contains("3rd commit").IsSelected(), Contains("2nd commit"), Contains("1st commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/find_base_commit_for_fixup_only_added_lines.go000066400000000000000000000044741500612110400327450ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FindBaseCommitForFixupOnlyAddedLines = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Finds the base commit to create a fixup for, when all staged hunks have only added lines", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch"). EmptyCommit("1st commit"). CreateFileAndAdd("file1", "line A\nline B\nline C\n"). Commit("2nd commit"). UpdateFileAndAdd("file1", "line A\nline B changed\nline C\n"). Commit("3rd commit"). CreateFileAndAdd("file2", "line X\nline Y\nline Z\n"). Commit("4th commit"). UpdateFile("file1", "line A\nline B changed\nline B'\nline C\n"). UpdateFile("file2", "line W\nline X\nline Y\nline Z\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("4th commit"), Contains("3rd commit"), Contains("2nd commit"), Contains("1st commit"), ) // Two changes from different commits: this fails t.Views().Files(). Focus(). Press(keys.Files.FindBaseCommitForFixup) t.ExpectPopup().Alert(). Title(Equals("Error")). Content( Contains("Multiple base commits found"). Contains("3rd commit"). Contains("4th commit"), ). Confirm() // Stage only one of the files: this succeeds t.Views().Files(). IsFocused(). NavigateToLine(Contains("file1")). PressPrimaryAction(). Press(keys.Files.FindBaseCommitForFixup) t.Views().Commits(). IsFocused(). Lines( Contains("4th commit"), Contains("3rd commit").IsSelected(), Contains("2nd commit"), Contains("1st commit"), ). Press(keys.Commits.AmendToCommit) t.ExpectPopup().Confirmation(). Title(Equals("Amend commit")). Content(Contains("Are you sure you want to amend this commit with your staged files?")). Confirm() // Now only the other file is modified (and unstaged); this works now t.Views().Files(). Focus(). Press(keys.Files.FindBaseCommitForFixup) t.Views().Commits(). IsFocused(). Lines( Contains("4th commit").IsSelected(), Contains("3rd commit"), Contains("2nd commit"), Contains("1st commit"), ) }, }) find_base_commit_for_fixup_warning_for_added_lines.go000066400000000000000000000026661500612110400342210ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/commitpackage commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FindBaseCommitForFixupWarningForAddedLines = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Finds the base commit to create a fixup for, and warns that there are hunks with only added lines", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch"). EmptyCommit("1st commit"). CreateFileAndAdd("file1", "file1 content\n"). Commit("2nd commit"). CreateFileAndAdd("file2", "file2 content\n"). Commit("3rd commit"). UpdateFile("file1", "file1 changed content"). UpdateFile("file2", "file2 content\nadded content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("3rd commit").IsSelected(), Contains("2nd commit"), Contains("1st commit"), ) t.Views().Files(). Focus(). Press(keys.Files.FindBaseCommitForFixup) t.ExpectPopup().Confirmation(). Title(Equals("Find base commit for fixup")). Content(Contains("There are ranges of only added lines in the diff; be careful to check that these belong in the found base commit.")). Confirm() t.Views().Commits(). IsFocused(). Lines( Contains("3rd commit"), Contains("2nd commit").IsSelected(), Contains("1st commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/highlight.go000066400000000000000000000017161500612110400237710ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Highlight = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that the commit view highlights the correct lines", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "always" config.GetUserConfig().Gui.AuthorColors = map[string]string{ "CI": "red", } }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.EmptyCommit("three") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { highlightedColor := "#ffffff" t.Views().Commits(). DoesNotContainColoredText(highlightedColor, "◯"). Focus(). ContainsColoredText(highlightedColor, "◯") t.Views().Files(). Focus() t.Views().Commits(). DoesNotContainColoredText(highlightedColor, "◯") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/history.go000066400000000000000000000027711500612110400235250ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var History = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cycling through commit message history in the commit message panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.CreateFile("myfile", "myfile content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). PressPrimaryAction(). // stage file Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")). Type("my commit message"). SelectPreviousMessage(). Content(Equals("commit 3")). SelectPreviousMessage(). Content(Equals("commit 2")). SelectPreviousMessage(). Content(Equals("initial commit")). SelectPreviousMessage(). Content(Equals("initial commit")). // we hit the end SelectNextMessage(). Content(Equals("commit 2")). SelectNextMessage(). Content(Equals("commit 3")). SelectNextMessage(). Content(Equals("my commit message")). SelectNextMessage(). Content(Equals("my commit message")). // we hit the beginning Type(" with extra added"). Confirm() t.Views().Commits(). TopLines( Contains("my commit message with extra added").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/history_complex.go000066400000000000000000000032071500612110400252470ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var HistoryComplex = NewIntegrationTest(NewIntegrationTestArgs{ Description: "More complex flow for cycling commit message history", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.CreateFileAndAdd("myfile", "myfile content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // We're going to start a new commit message, // then leave and try to reword a commit, then // come back to original message and confirm we haven't lost our message. // This shows that we're storing the preserved message for a new commit separately // to the message when cycling history. t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")). Type("my commit message"). Cancel() t.Views().Commits(). Focus(). SelectedLine(Contains("commit 3")). Press(keys.Commits.RenameCommit) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("commit 3")). SelectNextMessage(). Content(Equals("")). Type("reworded message"). SelectPreviousMessage(). Content(Equals("commit 3")). SelectNextMessage(). Content(Equals("reworded message")). Cancel() t.Views().Files(). Focus(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("my commit message")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/new_branch.go000066400000000000000000000017461500612110400241330ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NewBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Creating a new branch from a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("commit 1"). EmptyCommit("commit 2"). EmptyCommit("commit 3") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 3").IsSelected(), Contains("commit 2"), Contains("commit 1"), ). SelectNextItem(). Press(keys.Universal.New). Tap(func() { branchName := "my-branch-name" t.ExpectPopup().Prompt().Title(Contains("New branch name")).Type(branchName).Confirm() t.Git().CurrentBranchName(branchName) }). Lines( Contains("commit 2"), Contains("commit 1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/paste_commit_message.go000066400000000000000000000027351500612110400262140ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PasteCommitMessage = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Paste a commit message into the commit message panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > ../clipboard" config.GetUserConfig().OS.ReadFromClipboardCmd = "cat ../clipboard" }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("subject\n\nbody 1st line\nbody 2nd line") shell.CreateFileAndAdd("file", "file content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). ContainsLines( Contains("subject").IsSelected(), ). Press(keys.Commits.CopyCommitAttributeToClipboard) t.ExpectPopup().Menu().Title(Equals("Copy to clipboard")). Select(Contains("Commit message (subject and body)")).Confirm() t.ExpectToast(Equals("Commit message copied to clipboard")) t.Views().Files(). Focus(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). OpenCommitMenu() t.ExpectPopup().Menu().Title(Equals("Commit Menu")). Select(Contains("Paste commit message from clipboard")). Confirm() t.ExpectPopup().CommitMessagePanel(). Content(Equals("subject")). SwitchToDescription(). Content(Equals("body 1st line\nbody 2nd line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/paste_commit_message_over_existing.go000066400000000000000000000033701500612110400311550ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PasteCommitMessageOverExisting = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Paste a commit message into the commit message panel when there is already text in the panel, causing a confirmation", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > ../clipboard" config.GetUserConfig().OS.ReadFromClipboardCmd = "cat ../clipboard" }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("subject\n\nbody 1st line\nbody 2nd line") shell.CreateFileAndAdd("file", "file content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). ContainsLines( Contains("subject").IsSelected(), ). Press(keys.Commits.CopyCommitAttributeToClipboard) t.ExpectPopup().Menu().Title(Equals("Copy to clipboard")). Select(Contains("Commit message (subject and body)")).Confirm() t.ExpectToast(Equals("Commit message copied to clipboard")) t.Views().Files(). Focus(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Type("existing message"). OpenCommitMenu() t.ExpectPopup().Menu().Title(Equals("Commit Menu")). Select(Contains("Paste commit message from clipboard")). Confirm() t.ExpectPopup().Alert().Title(Equals("Paste commit message from clipboard")). Content(Equals("Pasting will overwrite the current commit message, continue?")). Confirm() t.ExpectPopup().CommitMessagePanel(). Content(Equals("subject")). SwitchToDescription(). Content(Equals("body 1st line\nbody 2nd line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/preserve_commit_message.go000066400000000000000000000027631500612110400267340ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PreserveCommitMessage = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Test that the commit message is preserved correctly when canceling the commit message panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "myfile content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")). Type("my commit message"). SwitchToDescription(). Type("first paragraph"). AddNewline(). AddNewline(). Type("second paragraph"). Cancel() t.FileSystem().PathPresent(".git/LAZYGIT_PENDING_COMMIT") t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Content(Equals("my commit message")). SwitchToDescription(). Content(Equals("first paragraph\n\nsecond paragraph")). Clear(). SwitchToSummary(). Clear(). Cancel() t.FileSystem().PathNotPresent(".git/LAZYGIT_PENDING_COMMIT") t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Type("my new commit message"). Confirm() t.FileSystem().PathNotPresent(".git/LAZYGIT_PENDING_COMMIT") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/reset_author.go000066400000000000000000000020011500612110400245120ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ResetAuthor = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reset author on a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.SetConfig("user.email", "Bill@example.com") shell.SetConfig("user.name", "Bill Smith") shell.EmptyCommit("one") shell.SetConfig("user.email", "John@example.com") shell.SetConfig("user.name", "John Smith") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("BS").Contains("one").IsSelected(), ). Press(keys.Commits.ResetCommitAuthor). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Amend commit attribute")). Select(Contains("Reset author")). Confirm() }). Lines( Contains("JS").Contains("one").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/reset_author_range.go000066400000000000000000000026771500612110400257110ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ResetAuthorRange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reset author on a range of commits", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.SetConfig("user.email", "Bill@example.com") shell.SetConfig("user.name", "Bill Smith") shell.EmptyCommit("fourth") shell.EmptyCommit("third") shell.EmptyCommit("second") shell.EmptyCommit("first") shell.SetConfig("user.email", "John@example.com") shell.SetConfig("user.name", "John Smith") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("BS").Contains("first").IsSelected(), Contains("BS").Contains("second"), Contains("BS").Contains("third"), Contains("BS").Contains("fourth"), ). SelectNextItem(). Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). Press(keys.Commits.ResetCommitAuthor). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Amend commit attribute")). Select(Contains("Reset author")). Confirm() }). PressEscape(). Lines( Contains("BS").Contains("first"), Contains("JS").Contains("second"), Contains("JS").Contains("third").IsSelected(), Contains("BS").Contains("fourth"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/revert.go000066400000000000000000000020021500612110400233160ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Revert = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reverts a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile("myfile", "myfile content") shell.GitAddAll() shell.Commit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit"), ). Press(keys.Commits.RevertCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Revert commit")). Content(MatchesRegexp(`Are you sure you want to revert \w+?`)). Confirm() }). Lines( Contains("Revert \"first commit\"").IsSelected(), Contains("first commit"), ) t.Views().Main().Content(Contains("-myfile content")) t.FileSystem().PathNotPresent("myfile") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/revert_merge.go000066400000000000000000000023451500612110400245070ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var RevertMerge = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reverts a merge commit and chooses to revert to the parent commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.CreateMergeCommit(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). TopLines( Contains("Merge branch 'second-change-branch' into first-change-branch").IsSelected(), ). Press(keys.Commits.RevertCommit) t.ExpectPopup().Confirmation(). Title(Equals("Revert commit")). Content(MatchesRegexp(`Are you sure you want to revert \w+?`)). Confirm() t.Views().Commits().IsFocused(). TopLines( Contains("Revert \"Merge branch 'second-change-branch' into first-change-branch\""), Contains("Merge branch 'second-change-branch' into first-change-branch").IsSelected(), ). SelectPreviousItem() t.Views().Main().Content(Contains("-Second Change").Contains("+First Change")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go000066400000000000000000000050531500612110400317110ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RevertWithConflictMultipleCommits = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reverts a range of commits, the first of which conflicts", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "") shell.Commit("add empty file") shell.CreateFileAndAdd("otherfile", "") shell.Commit("unrelated change") shell.CreateFileAndAdd("myfile", "first line\n") shell.Commit("add first line") shell.UpdateFileAndAdd("myfile", "first line\nsecond line\n") shell.Commit("add second line") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI ◯ add second line").IsSelected(), Contains("CI ◯ add first line"), Contains("CI ◯ unrelated change"), Contains("CI ◯ add empty file"), ). SelectNextItem(). Press(keys.Universal.RangeSelectDown). Press(keys.Commits.RevertCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Revert commit")). Content(Equals("Are you sure you want to revert the selected commits?")). Confirm() t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). Select(Contains("View conflicts")). Confirm() }). Lines( Contains("--- Pending reverts ---"), Contains("revert").Contains("CI unrelated change"), Contains("revert").Contains("CI <-- CONFLICT --- add first line"), Contains("--- Commits ---"), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ unrelated change"), Contains("CI ◯ add empty file"), ) t.Views().Options().Content(Contains("View revert options: m")) t.Views().Information().Content(Contains("Reverting (Reset)")) t.Views().Files().IsFocused(). Lines( Contains("UU myfile").IsSelected(), ). PressEnter() t.Views().MergeConflicts().IsFocused(). SelectNextItem(). PressPrimaryAction() t.ExpectPopup().Alert(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the revert?")). Confirm() t.Views().Commits(). Lines( Contains(`CI ◯ Revert "unrelated change"`), Contains(`CI ◯ Revert "add first line"`), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ unrelated change"), Contains("CI ◯ add empty file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/revert_with_conflict_single_commit.go000066400000000000000000000042531500612110400311550ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RevertWithConflictSingleCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reverts a commit that conflicts", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "") shell.Commit("add empty file") shell.CreateFileAndAdd("myfile", "first line\n") shell.Commit("add first line") shell.UpdateFileAndAdd("myfile", "first line\nsecond line\n") shell.Commit("add second line") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI ◯ add second line").IsSelected(), Contains("CI ◯ add first line"), Contains("CI ◯ add empty file"), ). SelectNextItem(). Press(keys.Commits.RevertCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Revert commit")). Content(MatchesRegexp(`Are you sure you want to revert \w+?`)). Confirm() t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). Select(Contains("View conflicts")). Confirm() }). Lines( Contains("--- Pending reverts ---"), Contains("revert").Contains("CI <-- CONFLICT --- add first line"), Contains("--- Commits ---"), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ add empty file"), ) t.Views().Options().Content(Contains("View revert options: m")) t.Views().Information().Content(Contains("Reverting (Reset)")) t.Views().Files().IsFocused(). Lines( Contains("UU myfile").IsSelected(), ). PressEnter() t.Views().MergeConflicts().IsFocused(). SelectNextItem(). PressPrimaryAction() t.ExpectPopup().Alert(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the revert?")). Confirm() t.Views().Commits(). Lines( Contains(`CI ◯ Revert "add first line"`), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ add empty file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/reword.go000066400000000000000000000033141500612110400233200ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Reword = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Staging a couple files and committing", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile("myfile", "myfile content") shell.CreateFile("myfile2", "myfile2 content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("myfile"), Contains("myfile2"), ). SelectNextItem(). PressPrimaryAction(). Press(keys.Files.CommitChanges) commitMessage := "my commit message" t.ExpectPopup().CommitMessagePanel().Type(commitMessage).Confirm() t.Views().Commits(). Lines( Contains(commitMessage), ) t.Views().Files(). IsFocused(). PressPrimaryAction(). Press(keys.Files.CommitChanges) wipCommitMessage := "my commit message wip" t.ExpectPopup().CommitMessagePanel().Type(wipCommitMessage).Close() t.Views().Commits().Focus(). Lines( Contains(commitMessage), ).Press(keys.Commits.RenameCommit) t.ExpectPopup().CommitMessagePanel(). SwitchToDescription(). Type("some description"). SwitchToSummary(). Confirm() t.Views().Main().Content(MatchesRegexp("my commit message\n\\s*some description")) t.Views().Files(). Focus(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel().Confirm() t.Views().Commits(). Lines( Contains(wipCommitMessage), Contains(commitMessage), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/search.go000066400000000000000000000047211500612110400232660ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Search = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Search for a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.EmptyCommit("three") shell.EmptyCommit("four") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.StartSearch). Tap(func() { t.ExpectSearch(). Type("two"). Confirm() t.Views().Search().IsVisible().Content(Contains("matches for 'two' (1 of 1)")) }). Lines( Contains("four"), Contains("three"), Contains("two").IsSelected(), Contains("one"), ). Press(keys.Universal.StartSearch). Tap(func() { t.ExpectSearch(). Clear(). Type("o"). Confirm() t.Views().Search().IsVisible().Content(Contains("matches for 'o' (2 of 3)")) }). Lines( Contains("four"), Contains("three"), Contains("two").IsSelected(), Contains("one"), ). Press("n"). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (3 of 3)")) }). Lines( Contains("four"), Contains("three"), Contains("two"), Contains("one").IsSelected(), ). Press("n"). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)")) }). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press("n"). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (2 of 3)")) }). Lines( Contains("four"), Contains("three"), Contains("two").IsSelected(), Contains("one"), ). Press("N"). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)")) }). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press("N"). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (3 of 3)")) }). Lines( Contains("four"), Contains("three"), Contains("two"), Contains("one").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/set_author.go000066400000000000000000000037011500612110400241730ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // Originally we only suggested authors present in the current branch, but now // we include authors from other branches whose commits you've looked at in the // lazygit session. var SetAuthor = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Set author on a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("original") shell.SetConfig("user.email", "Bill@example.com") shell.SetConfig("user.name", "Bill Smith") shell.EmptyCommit("one") shell.NewBranch("other") shell.SetConfig("user.email", "John@example.com") shell.SetConfig("user.name", "John Smith") shell.EmptyCommit("two") shell.Checkout("original") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("BS").Contains("one").IsSelected(), ) t.Views().Branches(). Focus(). Lines( Contains("original").IsSelected(), Contains("other"), ). NavigateToLine(Contains("other")). PressEnter() // ensuring we get these commit authors as suggestions t.Views().SubCommits(). IsFocused(). Lines( Contains("JS").Contains("two").IsSelected(), Contains("BS").Contains("one"), ) t.Views().Commits(). Focus(). Press(keys.Commits.ResetCommitAuthor). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Amend commit attribute")). Select(Contains(" Set author")). // adding space at start to distinguish from 'reset author' Confirm() t.ExpectPopup().Prompt(). Title(Contains("Set author")). SuggestionLines( Contains("Bill Smith"), Contains("John Smith"), ). ConfirmSuggestion(Contains("John Smith")) }). Lines( Contains("JS").Contains("one").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/set_author_range.go000066400000000000000000000030651500612110400253520ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SetAuthorRange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Set author on a range of commits", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.SetConfig("user.email", "Bill@example.com") shell.SetConfig("user.name", "Bill Smith") shell.EmptyCommit("fourth") shell.EmptyCommit("third") shell.EmptyCommit("second") shell.EmptyCommit("first") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("BS").Contains("first").IsSelected(), Contains("BS").Contains("second"), Contains("BS").Contains("third"), Contains("BS").Contains("fourth"), ) t.Views().Commits(). Focus(). SelectNextItem(). Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). Press(keys.Commits.ResetCommitAuthor). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Amend commit attribute")). Select(Contains(" Set author")). // adding space at start to distinguish from 'reset author' Confirm() t.ExpectPopup().Prompt(). Title(Contains("Set author")). Type("John Smith "). Confirm() }). PressEscape(). Lines( Contains("BS").Contains("first"), Contains("JS").Contains("second"), Contains("JS").Contains("third").IsSelected(), Contains("BS").Contains("fourth"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/shared.go000066400000000000000000000053561500612110400232740ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) func setupForAmendTests(shell *Shell) { shell.EmptyCommit("base commit") shell.NewBranch("branch") shell.Checkout("master") shell.CreateFileAndAdd("file1", "master") shell.Commit("file1 changed in master") shell.Checkout("branch") shell.UpdateFileAndAdd("file2", "two") shell.Commit("commit two") shell.CreateFileAndAdd("file1", "branch") shell.Commit("file1 changed in branch") shell.UpdateFileAndAdd("file3", "three") shell.Commit("commit three") } func doTheRebaseForAmendTests(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit three").IsSelected(), Contains("file1 changed in branch"), Contains("commit two"), Contains("base commit"), ) t.Views().Branches(). Focus(). NavigateToLine(Contains("master")). Press(keys.Branches.RebaseBranch). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Rebase 'branch'")). Select(Contains("Simple rebase")). Confirm() t.Common().AcknowledgeConflicts() }) t.Views().Commits(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit three"), Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), Contains("--- Commits ---"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), ) t.Views().Files(). Focus(). PressEnter() t.Views().MergeConflicts(). IsFocused(). SelectNextItem(). // choose "incoming" PressPrimaryAction() t.ExpectPopup().Confirmation(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the rebase?")). Cancel() } func checkCommitContainsChange(t *TestDriver, commitSubject string, change string) { t.Views().Commits(). Focus(). NavigateToLine(Contains(commitSubject)) t.Views().Main(). Content(Contains(change)) } func checkBlockingHook(t *TestDriver, keys config.KeybindingConfig) { // Shared function for tests using the blockingHook pre-commit hook for testing hook skipping // Stage first file t.Views().Files(). IsFocused(). PressPrimaryAction(). Press(keys.Files.CommitChanges) // Try to commit with hook t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). Type("Commit should fail"). Confirm() t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("Git command failed.")). Confirm() // Clear the message t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). Clear(). Cancel() // Unstage the file t.Views().Files(). IsFocused(). PressPrimaryAction() } lazygit-0.50.0+ds1/pkg/integration/tests/commit/stage_range_of_lines.go000066400000000000000000000021311500612110400261470ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StageRangeOfLines = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Staging a range of lines", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "1st\n2nd\n3rd\n4th\n5th\n6th\n") shell.Commit("Add file") shell.UpdateFile("myfile", "1st changed\n2nd changed\n3rd\n4th\n5th changed\n6th\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). PressEnter() t.Views().Staging(). Content( Contains("-1st\n-2nd\n+1st changed\n+2nd changed\n 3rd\n 4th\n-5th\n+5th changed\n 6th"), ). SelectedLine(Equals("-1st")). Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). SelectNextItem(). SelectNextItem(). SelectNextItem(). PressPrimaryAction(). Content( Contains(" 3rd\n 4th\n-5th\n+5th changed\n 6th"), ). SelectedLine(Equals("-5th")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/staged.go000066400000000000000000000040451500612110400232670ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Staged = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Staging a couple files, going in the staged files menu, unstaging a line then committing", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateFile("myfile", "myfile content\nwith a second line"). CreateFile("myfile2", "myfile2 content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("myfile"), Contains("myfile2"), ). SelectNextItem(). PressPrimaryAction(). // stage the file PressEnter() t.Views().StagingSecondary(). IsFocused(). Tap(func() { // we start with both lines having been staged t.Views().StagingSecondary().Content(Contains("+myfile content")) t.Views().StagingSecondary().Content(Contains("+with a second line")) t.Views().Staging().Content(DoesNotContain("+myfile content")) t.Views().Staging().Content(DoesNotContain("+with a second line")) }). // unstage the selected line PressPrimaryAction(). Tap(func() { // the line should have been moved to the main view t.Views().StagingSecondary().Content(DoesNotContain("+myfile content")) t.Views().StagingSecondary().Content(Contains("+with a second line")) t.Views().Staging().Content(Contains("+myfile content")) t.Views().Staging().Content(DoesNotContain("+with a second line")) }). Press(keys.Files.CommitChanges) commitMessage := "my commit message" t.ExpectPopup().CommitMessagePanel().Type(commitMessage).Confirm() t.Views().Commits(). Lines( Contains(commitMessage), ) t.Views().StagingSecondary(). IsEmpty() t.Views().Staging(). IsFocused(). Content(Contains("+myfile content")). Content(DoesNotContain("+with a second line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/staged_without_hooks.go000066400000000000000000000040401500612110400262500ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StagedWithoutHooks = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Staging a couple files, going in the staged files menu, unstaging a line then committing without pre-commit hooks", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile(".git/hooks/pre-commit", blockingHook) shell.MakeExecutable(".git/hooks/pre-commit") shell. CreateFile("myfile", "myfile content\nwith a second line"). CreateFile("myfile2", "myfile2 content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() checkBlockingHook(t, keys) // stage the file t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("myfile"), Contains("myfile2"), ). SelectNextItem(). PressPrimaryAction(). PressEnter() // we start with both lines having been staged t.Views().StagingSecondary().Content( Contains("+myfile content").Contains("+with a second line"), ) t.Views().Staging().Content( DoesNotContain("+myfile content").DoesNotContain("+with a second line"), ) // unstage the selected line t.Views().StagingSecondary(). IsFocused(). PressPrimaryAction(). Tap(func() { // the line should have been moved to the main view t.Views().Staging().Content(Contains("+myfile content").DoesNotContain("+with a second line")) }). Content(DoesNotContain("+myfile content").Contains("+with a second line")). Press(keys.Files.CommitChangesWithoutHook) commitMessage := "my commit message" t.ExpectPopup().CommitMessagePanel().Type(commitMessage).Confirm() t.Views().Commits(). Lines( Contains(commitMessage), ) t.Views().StagingSecondary(). IsEmpty() t.Views().Staging(). IsFocused(). Content(Contains("+myfile content")). Content(DoesNotContain("+with a second line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/commit/unstaged.go000066400000000000000000000031641500612110400236330ustar00rootroot00000000000000package commit import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Unstaged = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Staging a couple files, going in the unstaged files menu, staging a line and committing", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateFile("myfile", "myfile content\nwith a second line"). CreateFile("myfile2", "myfile2 content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). IsEmpty() t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("myfile"), Contains("myfile2"), ). SelectNextItem(). PressEnter() t.Views().Staging(). IsFocused(). Tap(func() { t.Views().StagingSecondary().Content(DoesNotContain("+myfile content")) t.Views().Staging().SelectedLine(Equals("+myfile content")) }). // stage the first line PressPrimaryAction(). Tap(func() { t.Views().Staging().Content(DoesNotContain("+myfile content")). SelectedLine(Equals("+with a second line")) t.Views().StagingSecondary().Content(Contains("+myfile content")) }). Press(keys.Files.CommitChanges) commitMessage := "my commit message" t.ExpectPopup().CommitMessagePanel().Type(commitMessage).Confirm() t.Views().Commits(). Lines( Contains(commitMessage), ) t.Views().Staging().IsFocused() // TODO: assert that the staging panel has been refreshed (it currently does not get correctly refreshed) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/config/000077500000000000000000000000001500612110400214435ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/config/custom_commands_in_per_repo_config.go000066400000000000000000000032041500612110400310720ustar00rootroot00000000000000package config import ( "path/filepath" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CustomCommandsInPerRepoConfig = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Custom commands in per-repo config add to the global ones instead of replacing them", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { otherRepo, _ := filepath.Abs("../other") cfg.GetAppState().RecentRepos = []string{otherRepo} cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "global", Command: "printf 'global X' > file.txt", }, { Key: "Y", Context: "global", Command: "printf 'global Y' > file.txt", }, } }, SetupRepo: func(shell *Shell) { shell.CloneNonBare("other") shell.CreateFile("../other/.git/lazygit.yml", ` customCommands: - key: Y context: global command: printf 'local Y' > file.txt - key: Z context: global command: printf 'local Z' > file.txt`) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.GlobalPress(keys.Universal.OpenRecentRepos) t.ExpectPopup().Menu().Title(Equals("Recent repositories")). Lines( Contains("other").IsSelected(), Contains("Cancel"), ).Confirm() t.Views().Status().Content(Contains("other → master")) t.GlobalPress("X") t.FileSystem().FileContent("../other/file.txt", Equals("global X")) t.GlobalPress("Y") t.FileSystem().FileContent("../other/file.txt", Equals("local Y")) t.GlobalPress("Z") t.FileSystem().FileContent("../other/file.txt", Equals("local Z")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/config/negative_refspec.go000066400000000000000000000012711500612110400253040ustar00rootroot00000000000000package config import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NegativeRefspec = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Having a config with a negative refspec", ExtraCmdArgs: []string{}, GitVersion: AtLeast("2.29.0"), SetupRepo: func(shell *Shell) { shell. SetConfig("remote.origin.fetch", "^refs/heads/test"). CreateNCommits(2) }, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { // the failure case with an unpatched go-git is that no branches display t.Views().Branches(). Lines( Contains("master"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/config/remote_named_star.go000066400000000000000000000012311500612110400254570ustar00rootroot00000000000000package config import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RemoteNamedStar = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Having a config remote.*", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell. SetConfig("remote.*.prune", "true"). CreateNCommits(2) }, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { // here we're just asserting that we haven't panicked upon starting lazygit t.Views().Commits(). Lines( AnyString(), AnyString(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/conflicts/000077500000000000000000000000001500612110400221625ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/conflicts/filter.go000066400000000000000000000021131500612110400237730ustar00rootroot00000000000000package conflicts import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var Filter = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Ensures that when there are merge conflicts, the files panel only shows conflicted files", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.CreateMergeConflictFiles(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" UU file1"), Equals(" UU file2"), ). Press(keys.Files.OpenStatusFilter). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("No filter")). Confirm() }). Lines( Equals("▼ /").IsSelected(), Equals(" UU file1"), Equals(" UU file2"), // now we see the non-merge conflict file Equals(" A file3"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/conflicts/resolve_externally.go000066400000000000000000000016271500612110400264450ustar00rootroot00000000000000package conflicts import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var ResolveExternally = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Ensures that when merge conflicts are resolved outside of lazygit, lazygit prompts you to continue", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.CreateMergeConflictFile(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("UU file").IsSelected(), ). Tap(func() { t.Shell().UpdateFile("file", "resolved content") }). Press(keys.Universal.Refresh) t.Common().ContinueOnConflictsResolved("merge") t.Views().Files(). IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/conflicts/resolve_multiple_files.go000066400000000000000000000025251500612110400272710ustar00rootroot00000000000000package conflicts import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Ensures that upon resolving conflicts for one file, the next file is selected", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.CreateMergeConflictFiles(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" UU file1"), Equals(" UU file2"), ). SelectNextItem(). PressEnter() t.Views().MergeConflicts(). IsFocused(). SelectedLines( Contains("<<<<<<< HEAD"), Contains("First Change"), Contains("======="), ). PressPrimaryAction() t.Views().Files(). IsFocused(). Lines( Equals("UU file2").IsSelected(), ). PressEnter() // coincidentally these files have the same conflict t.Views().MergeConflicts(). IsFocused(). SelectedLines( Contains("<<<<<<< HEAD"), Contains("First Change"), Contains("======="), ). PressPrimaryAction() t.Common().ContinueOnConflictsResolved("merge") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/conflicts/resolve_no_auto_stage.go000066400000000000000000000040641500612110400271030ustar00rootroot00000000000000package conflicts import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var ResolveNoAutoStage = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Resolving conflicts without auto-staging", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.AutoStageResolvedConflicts = false }, SetupRepo: func(shell *Shell) { shared.CreateMergeConflictFiles(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" UU file1"), Equals(" UU file2"), ). SelectNextItem(). PressEnter() t.Views().MergeConflicts(). IsFocused(). SelectedLines( Contains("<<<<<<< HEAD"), Contains("First Change"), Contains("======="), ). PressPrimaryAction() t.Views().Files(). IsFocused(). // Resolving the conflict didn't auto-stage it Lines( Equals("▼ /"), Equals(" UU file1").IsSelected(), Equals(" UU file2"), ). // So do that manually PressPrimaryAction(). Lines( Equals("UU file2").IsSelected(), ). // Trying to stage a file that still has conflicts is not allowed: PressPrimaryAction(). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("Cannot stage/unstage directory containing files with inline merge conflicts.")). Confirm() }). PressEnter() // coincidentally these files have the same conflict t.Views().MergeConflicts(). IsFocused(). SelectedLines( Contains("<<<<<<< HEAD"), Contains("First Change"), Contains("======="), ). PressPrimaryAction() t.Views().Files(). IsFocused(). // Again, resolving the conflict didn't auto-stage it Lines( Equals("UU file2").IsSelected(), ). // Doing that manually now works: PressPrimaryAction(). Lines( Equals("A file3").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/conflicts/resolve_non_textual_conflicts.go000066400000000000000000000105201500612110400306520ustar00rootroot00000000000000package conflicts import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ResolveNonTextualConflicts = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Resolve non-textual merge conflicts (e.g. one side modified, the other side deleted)", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.RunShellCommand(`echo test1 > both-deleted1.txt`) shell.RunShellCommand(`echo test2 > both-deleted2.txt`) shell.RunShellCommand(`git checkout -b conflict && git add both-deleted1.txt both-deleted2.txt`) shell.RunShellCommand(`echo haha1 > deleted-them1.txt && git add deleted-them1.txt`) shell.RunShellCommand(`echo haha2 > deleted-them2.txt && git add deleted-them2.txt`) shell.RunShellCommand(`echo haha1 > deleted-us1.txt && git add deleted-us1.txt`) shell.RunShellCommand(`echo haha2 > deleted-us2.txt && git add deleted-us2.txt`) shell.RunShellCommand(`git commit -m one`) // stuff on other branch shell.RunShellCommand(`git branch conflict_second`) shell.RunShellCommand(`git mv both-deleted1.txt added-them-changed-us1.txt`) shell.RunShellCommand(`git mv both-deleted2.txt added-them-changed-us2.txt`) shell.RunShellCommand(`git rm deleted-them1.txt deleted-them2.txt`) shell.RunShellCommand(`echo modded1 > deleted-us1.txt && git add deleted-us1.txt`) shell.RunShellCommand(`echo modded2 > deleted-us2.txt && git add deleted-us2.txt`) shell.RunShellCommand(`git commit -m "two"`) // stuff on our branch shell.RunShellCommand(`git checkout conflict_second`) shell.RunShellCommand(`git mv both-deleted1.txt changed-them-added-us1.txt`) shell.RunShellCommand(`git mv both-deleted2.txt changed-them-added-us2.txt`) shell.RunShellCommand(`echo modded1 > deleted-them1.txt && git add deleted-them1.txt`) shell.RunShellCommand(`echo modded2 > deleted-them2.txt && git add deleted-them2.txt`) shell.RunShellCommand(`git rm deleted-us1.txt deleted-us2.txt`) shell.RunShellCommand(`git commit -m "three"`) shell.RunShellCommand(`git reset --hard conflict_second`) shell.RunCommandExpectError([]string{"git", "merge", "conflict"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { resolve := func(filename string, menuChoice string) { t.Views().Files(). NavigateToLine(Contains(filename)). Tap(func() { t.Views().Main().Content(Contains("Conflict:")) }). Press(keys.Universal.GoInto). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Merge conflicts")). Select(Contains(menuChoice)). Confirm() }) } t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" UA added-them-changed-us1.txt"), Equals(" UA added-them-changed-us2.txt"), Equals(" DD both-deleted1.txt"), Equals(" DD both-deleted2.txt"), Equals(" AU changed-them-added-us1.txt"), Equals(" AU changed-them-added-us2.txt"), Equals(" UD deleted-them1.txt"), Equals(" UD deleted-them2.txt"), Equals(" DU deleted-us1.txt"), Equals(" DU deleted-us2.txt"), ). Tap(func() { resolve("added-them-changed-us1.txt", "Delete file") resolve("added-them-changed-us2.txt", "Keep file") resolve("both-deleted1.txt", "Delete file") resolve("both-deleted2.txt", "Delete file") resolve("changed-them-added-us1.txt", "Delete file") resolve("changed-them-added-us2.txt", "Keep file") resolve("deleted-them1.txt", "Delete file") resolve("deleted-them2.txt", "Keep file") resolve("deleted-us1.txt", "Delete file") resolve("deleted-us2.txt", "Keep file") }). Lines( Equals("▼ /"), Equals(" A added-them-changed-us2.txt"), Equals(" D changed-them-added-us1.txt"), Equals(" D deleted-them1.txt"), Equals(" A deleted-us2.txt"), ) t.FileSystem(). PathNotPresent("added-them-changed-us1.txt"). FileContent("added-them-changed-us2.txt", Equals("test2\n")). PathNotPresent("both-deleted1.txt"). PathNotPresent("both-deleted2.txt"). PathNotPresent("changed-them-added-us1.txt"). FileContent("changed-them-added-us2.txt", Equals("test2\n")). PathNotPresent("deleted-them1.txt"). FileContent("deleted-them2.txt", Equals("modded2\n")). PathNotPresent("deleted-us1.txt"). FileContent("deleted-us2.txt", Equals("modded2\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go000066400000000000000000000027771500612110400303420ustar00rootroot00000000000000package conflicts import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ResolveWithoutTrailingLf = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Regression test for resolving a merge conflict when the file doesn't have a trailing newline", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. NewBranch("branch1"). CreateFileAndAdd("file", "a\n\nno eol"). Commit("initial commit"). UpdateFileAndAdd("file", "a1\n\nno eol"). Commit("commit on branch1"). NewBranchFrom("branch2", "HEAD^"). UpdateFileAndAdd("file", "a2\n\nno eol"). Commit("commit on branch2"). Checkout("branch1"). RunCommandExpectError([]string{"git", "merge", "--no-edit", "branch2"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("UU file").IsSelected(), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). SelectedLines( Contains("<<<<<<< HEAD"), Contains("a1"), Contains("======="), ). SelectNextItem(). PressPrimaryAction() t.ExpectPopup().Alert(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the merge?")). Cancel() t.Views().Files(). Focus(). Lines( Contains("M file").IsSelected(), ) t.Views().Main().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/conflicts/undo_choose_hunk.go000066400000000000000000000023501500612110400260430ustar00rootroot00000000000000package conflicts import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var UndoChooseHunk = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Chooses a hunk when resolving a merge conflict and then undoes the choice", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.CreateMergeConflictFileMultiple(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("UU file").IsSelected(), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). Content(Contains("<<<<<<< HEAD\nFirst Change")). // explicitly asserting on the selection because sometimes the content renders // before the selection is ready for user input SelectedLines( Contains("<<<<<<< HEAD"), Contains("First Change"), Contains("======="), ). PressPrimaryAction(). // choosing the first hunk Content(DoesNotContain("<<<<<<< HEAD\nFirst Change")). Press(keys.Universal.Undo). Content(Contains("<<<<<<< HEAD\nFirst Change")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/000077500000000000000000000000001500612110400233715ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/access_commit_properties.go000066400000000000000000000020171500612110400310050ustar00rootroot00000000000000package custom_commands import ( "fmt" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AccessCommitProperties = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Run a command that accesses properties of a commit", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("my change") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "commits", Command: "printf '%s\n%s\n%s' '{{ .SelectedLocalCommit.Name }}' '{{ .SelectedLocalCommit.Hash }}' '{{ .SelectedLocalCommit.Sha }}' > file.txt", }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("my change").IsSelected(), ). Press("X") hash := t.Git().GetCommitHash("HEAD") t.FileSystem().FileContent("file.txt", Equals(fmt.Sprintf("my change\n%s\n%s", hash, hash))) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/basic_command.go000066400000000000000000000013601500612110400264770ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var BasicCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using a custom command to create a new file", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "files", Command: "touch myfile", }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsEmpty(). IsFocused(). Press("a"). Lines( Contains("myfile"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/check_for_conflicts.go000066400000000000000000000020741500612110400277120ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var CheckForConflicts = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Run a command and check for conflicts after", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shared.MergeConflictsSetup(shell) }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "m", Context: "localBranches", Command: "git merge {{ .SelectedLocalBranch.Name | quote }}", After: &config.CustomCommandAfterHook{ CheckForConflicts: true, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). TopLines( Contains("first-change-branch"), Contains("second-change-branch"), ). NavigateToLine(Contains("second-change-branch")). Press("m") t.Common().AcknowledgeConflicts() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/custom_commands_submenu.go000066400000000000000000000032601500612110400306520ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CustomCommandsSubmenu = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using custom commands from a custom commands menu", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) {}, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "x", Description: "My Custom Commands", CommandMenu: []config.CustomCommand{ { Key: "1", Context: "global", Command: "touch myfile-global", }, { Key: "2", Context: "files", Command: "touch myfile-files", }, { Key: "3", Context: "commits", Command: "touch myfile-commits", }, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Focus(). IsEmpty(). Press("x"). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("My Custom Commands")). Lines( Contains("1 touch myfile-global"), Contains("2 touch myfile-files"), ). Select(Contains("touch myfile-files")).Confirm() }). Lines( Contains("myfile-files"), ) t.Views().Commits(). Focus(). Press("x"). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("My Custom Commands")). Lines( Contains("1 touch myfile-global"), Contains("3 touch myfile-commits"), ) t.GlobalPress("3") }) t.Views().Files(). Lines( Equals("▼ /"), Contains("myfile-commits"), Contains("myfile-files"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/form_prompts.go000066400000000000000000000037441500612110400264570ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FormPrompts = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using a custom command referring prompt responses by name", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "files", Command: `echo {{.Form.FileContent | quote}} > {{.Form.FileName | quote}}`, Prompts: []config.CustomCommandPrompt{ { Key: "FileName", Type: "input", Title: "Enter a file name", }, { Key: "FileContent", Type: "menu", Title: "Choose file content", Options: []config.CustomCommandMenuOption{ { Name: "foo", Description: "Foo", Value: "FOO", }, { Name: "bar", Description: "Bar", Value: `"BAR"`, }, { Name: "baz", Description: "Baz", Value: "BAZ", }, }, }, { Type: "confirm", Title: "Are you sure?", Body: "Are you REALLY sure you want to make this file? Up to you buddy.", }, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsEmpty(). IsFocused(). Press("a") t.ExpectPopup().Prompt().Title(Equals("Enter a file name")).Type("my file").Confirm() t.ExpectPopup().Menu().Title(Equals("Choose file content")).Select(Contains("bar")).Confirm() t.ExpectPopup().Confirmation(). Title(Equals("Are you sure?")). Content(Equals("Are you REALLY sure you want to make this file? Up to you buddy.")). Confirm() t.Views().Files(). Lines( Contains("my file").IsSelected(), ) t.Views().Main().Content(Contains(`"BAR"`)) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/global_context.go000066400000000000000000000022551500612110400267300ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var GlobalContext = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Ensure global context works", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("my change") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "global", Command: "touch myfile", }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // commits t.Views().Commits(). Focus(). Press("X") t.Views().Files(). Focus(). Lines(Contains("myfile")) t.Shell().DeleteFile("myfile") t.GlobalPress(keys.Files.RefreshFiles) // branches t.Views().Branches(). Focus(). Press("X") t.Views().Files(). Focus(). Lines(Contains("myfile")) t.Shell().DeleteFile("myfile") t.GlobalPress(keys.Files.RefreshFiles) // files t.Views().Files(). Focus(). Press("X") t.Views().Files(). Focus(). Lines(Contains("myfile")) t.Shell().DeleteFile("myfile") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/menu_from_command.go000066400000000000000000000035551500612110400274150ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // NOTE: we're getting a weird offset in the popup prompt for some reason. Not sure what's behind that. var MenuFromCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using menuFromCommand prompt type", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell. EmptyCommit("foo"). EmptyCommit("bar"). EmptyCommit("baz"). NewBranch("feature/foo") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "localBranches", Command: `echo "{{index .PromptResponses 0}} {{index .PromptResponses 1}} {{ .SelectedLocalBranch.Name }}" > output.txt`, Prompts: []config.CustomCommandPrompt{ { Type: "menuFromCommand", Title: "Choose commit message", Command: `git log --oneline --pretty=%B`, Filter: `(?P.*)`, ValueFormat: `{{ .commit_message }}`, LabelFormat: `{{ .commit_message | yellow }}`, }, { Type: "input", Title: "Description", InitialValue: `{{ if .SelectedLocalBranch.Name }}Branch: #{{ .SelectedLocalBranch.Name }}{{end}}`, }, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsEmpty() t.Views().Branches(). Focus(). Press("a") t.ExpectPopup().Menu().Title(Equals("Choose commit message")).Select(Contains("bar")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Description")).Type(" my branch").Confirm() t.Views().Files(). Focus(). Lines( Contains("output.txt").IsSelected(), ) t.Views().Main().Content(Contains("bar Branch: #feature/foo my branch feature/foo")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/menu_from_commands_output.go000066400000000000000000000031401500612110400312060ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MenuFromCommandsOutput = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using prompt response in menuFromCommand entries", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell. EmptyCommit("foo"). NewBranch("feature/foo"). EmptyCommit("bar"). NewBranch("feature/bar"). EmptyCommit("baz") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "localBranches", Command: "git checkout {{ index .PromptResponses 1 }}", Prompts: []config.CustomCommandPrompt{ { Type: "input", Title: "Which git command do you want to run?", InitialValue: "branch", }, { Type: "menuFromCommand", Title: "Branch:", Command: `git {{ index .PromptResponses 0 }} --format='%(refname:short)'`, Filter: "(?P.*)", ValueFormat: `{{ .branch }}`, LabelFormat: `{{ .branch | green }}`, }, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Git().CurrentBranchName("feature/bar") t.Views().Branches(). Focus(). Press("a") t.ExpectPopup().Prompt(). Title(Equals("Which git command do you want to run?")). InitialText(Equals("branch")). Confirm() t.ExpectPopup().Menu().Title(Equals("Branch:")).Select(Equals("master")).Confirm() t.Git().CurrentBranchName("master") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/multiple_contexts.go000066400000000000000000000021631500612110400275040ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MultipleContexts = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Test that multiple contexts works", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("my change") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "commits, reflogCommits", Command: "touch myfile", }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // commits t.Views().Commits(). Focus(). Press("X") t.Views().Files(). Focus(). Lines(Contains("myfile")) t.Shell().DeleteFile("myfile") t.GlobalPress(keys.Files.RefreshFiles) // branches t.Views().Branches(). Focus(). Press("X") t.Views().Files(). Focus(). IsEmpty() // files t.Views().ReflogCommits(). Focus(). Press("X") t.Views().Files(). Focus(). Lines(Contains("myfile")) t.Shell().DeleteFile("myfile") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/multiple_prompts.go000066400000000000000000000036571500612110400273520ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MultiplePrompts = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using a custom command with multiple prompts", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "files", Command: `echo "{{index .PromptResponses 1}}" > {{index .PromptResponses 0}}`, Prompts: []config.CustomCommandPrompt{ { Type: "input", Title: "Enter a file name", }, { Type: "menu", Title: "Choose file content", Options: []config.CustomCommandMenuOption{ { Name: "foo", Description: "Foo", Value: "FOO", }, { Name: "bar", Description: "Bar", Value: "BAR", }, { Name: "baz", Description: "Baz", Value: "BAZ", }, }, }, { Type: "confirm", Title: "Are you sure?", Body: "Are you REALLY sure you want to make this file? Up to you buddy.", }, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsEmpty(). IsFocused(). Press("a") t.ExpectPopup().Prompt().Title(Equals("Enter a file name")).Type("myfile").Confirm() t.ExpectPopup().Menu().Title(Equals("Choose file content")).Select(Contains("bar")).Confirm() t.ExpectPopup().Confirmation(). Title(Equals("Are you sure?")). Content(Equals("Are you REALLY sure you want to make this file? Up to you buddy.")). Confirm() t.Views().Files(). Focus(). Lines( Contains("myfile").IsSelected(), ) t.Views().Main().Content(Contains("BAR")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/run_command.go000066400000000000000000000021371500612110400262250ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RunCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using a custom command that uses runCommand template function in a prompt step", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "localBranches", Command: `git checkout {{.Form.Branch}}`, Prompts: []config.CustomCommandPrompt{ { Key: "Branch", Type: "input", Title: "Enter a branch name", InitialValue: "myprefix/{{ runCommand \"echo dynamic\" }}/", }, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Press("a") t.ExpectPopup().Prompt(). Title(Equals("Enter a branch name")). InitialText(Contains("myprefix/dynamic/")). Confirm() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/selected_commit.go000066400000000000000000000036671500612110400270740ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SelectedCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Use the {{ .SelectedCommit }} template variable in different contexts", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.CreateNCommits(3) }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "global", Command: "printf '%s' '{{ .SelectedCommit.Name }}' > file.txt", }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // Select different commits in each of the commit views t.Views().Commits().Focus(). NavigateToLine(Contains("commit 01")) t.Views().ReflogCommits().Focus(). NavigateToLine(Contains("commit 02")) t.Views().Branches().Focus(). Lines(Contains("master").IsSelected()). PressEnter() t.Views().SubCommits().IsFocused(). NavigateToLine(Contains("commit 03")) // SubCommits t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit 03")) t.Views().SubCommits().PressEnter() t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit 03")) // ReflogCommits t.Views().ReflogCommits().Focus() t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit: commit 02")) t.Views().ReflogCommits().PressEnter() t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit: commit 02")) // LocalCommits t.Views().Commits().Focus() t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit 01")) t.Views().Commits().PressEnter() t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit 01")) // None of these t.Views().Files().Focus() t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit 01")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/selected_commit_range.go000066400000000000000000000021621500612110400302350ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SelectedCommitRange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Use the {{ .SelectedCommitRange }} template variable", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.CreateNCommits(3) }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "global", Command: `git log --format="%s" {{.SelectedCommitRange.From}}^..{{.SelectedCommitRange.To}} > file.txt`, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), ) t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit 03\n")) t.Views().Commits().Focus(). Press(keys.Universal.RangeSelectDown) t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("commit 03\ncommit 02\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/selected_path.go000066400000000000000000000023051500612110400265240ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SelectedPath = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Use the {{ .SelectedPath }} template variable in different contexts", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.CreateDir("folder1") shell.CreateFileAndAdd("folder1/file1", "") shell.Commit("commit") shell.CreateDir("folder2") shell.CreateFile("folder2/file2", "") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "global", Command: "printf '%s' '{{ .SelectedPath }}' > file.txt", }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Focus(). NavigateToLine(Contains("file2")) t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("folder2/file2")) t.Views().Commits(). Focus(). PressEnter() t.Views().CommitFiles(). IsFocused(). NavigateToLine(Contains("file1")) t.GlobalPress("X") t.FileSystem().FileContent("file.txt", Equals("folder1/file1")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/show_output_in_panel.go000066400000000000000000000027641500612110400301760ustar00rootroot00000000000000package custom_commands import ( "fmt" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ShowOutputInPanel = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Run a command and show the output in a panel", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("my change") }, SetupConfig: func(cfg *config.AppConfig) { trueVal := true cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "commits", Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", ShowOutput: &trueVal, }, { Key: "Y", Context: "commits", Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", ShowOutput: &trueVal, OutputTitle: "Subject of commit {{ .SelectedLocalCommit.Hash }}", }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("my change").IsSelected(), ). Press("X") t.ExpectPopup().Alert(). // Uses cmd string as title if no outputTitle is provided Title(Equals("printf '%s' 'my change'")). Content(Equals("my change")). Confirm() t.Views().Commits(). Press("Y") hash := t.Git().GetCommitHash("HEAD") t.ExpectPopup().Alert(). // Uses provided outputTitle with template fields resolved Title(Equals(fmt.Sprintf("Subject of commit %s", hash))). Content(Equals("my change")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/suggestions_command.go000066400000000000000000000032561500612110400277760ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SuggestionsCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using a custom command that uses a suggestions command in a prompt step", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-one") shell.EmptyCommit("blah") shell.NewBranch("branch-two") shell.EmptyCommit("blah") shell.NewBranch("branch-three") shell.EmptyCommit("blah") shell.NewBranch("branch-four") shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "localBranches", Command: `git checkout {{.Form.Branch}}`, Prompts: []config.CustomCommandPrompt{ { Key: "Branch", Type: "input", Title: "Enter a branch name", Suggestions: config.CustomCommandSuggestions{ Command: "git branch --format='%(refname:short)'", }, }, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("branch-four").IsSelected(), Contains("branch-three"), Contains("branch-two"), Contains("branch-one"), ). Press("a") t.ExpectPopup().Prompt(). Title(Equals("Enter a branch name")). Type("three"). SuggestionLines(Contains("branch-three")). ConfirmFirstSuggestion() t.Views().Branches(). Lines( Contains("branch-three"), Contains("branch-four").IsSelected(), Contains("branch-two"), Contains("branch-one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/custom_commands/suggestions_preset.go000066400000000000000000000032151500612110400276550ustar00rootroot00000000000000package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SuggestionsPreset = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using a custom command that uses a suggestions preset in a prompt step", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-one") shell.EmptyCommit("blah") shell.NewBranch("branch-two") shell.EmptyCommit("blah") shell.NewBranch("branch-three") shell.EmptyCommit("blah") shell.NewBranch("branch-four") shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "localBranches", Command: `git checkout {{.Form.Branch}}`, Prompts: []config.CustomCommandPrompt{ { Key: "Branch", Type: "input", Title: "Enter a branch name", Suggestions: config.CustomCommandSuggestions{ Preset: "branches", }, }, }, }, } }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("branch-four").IsSelected(), Contains("branch-three"), Contains("branch-two"), Contains("branch-one"), ). Press("a") t.ExpectPopup().Prompt(). Title(Equals("Enter a branch name")). Type("three"). SuggestionLines(Contains("branch-three")). ConfirmFirstSuggestion() t.Views().Branches(). Lines( Contains("branch-three"), Contains("branch-four").IsSelected(), Contains("branch-two"), Contains("branch-one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/000077500000000000000000000000001500612110400211225ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/demo/amend_old_commit.go000066400000000000000000000030671500612110400247510ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendOldCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amend old commit", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) config.GetUserConfig().Gui.ShowFileTree = false }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(60) shell.NewBranch("feature/demo") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("feature/demo", "origin/feature/demo") shell.UpdateFile("navigation/site_navigation.go", "package navigation\n\nfunc Navigate() {\n\tpanic(\"unimplemented\")\n}") shell.CreateFile("docs/README.md", "my readme content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Amend an old commit") t.Wait(1000) t.Views().Files(). IsFocused(). SelectedLine(Contains("site_navigation.go")). PressPrimaryAction() t.Views().Commits(). Focus(). NavigateToLine(Contains("Improve accessibility of site navigation")). Wait(500). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Amend commit")). Wait(1000). Content(AnyString()). Confirm() t.Wait(1000) }). Press(keys.Universal.Push). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Force push")). Content(AnyString()). Wait(1000). Confirm() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/bisect.go000066400000000000000000000044371500612110400227320ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Bisect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Interactive rebase", ExtraCmdArgs: []string{"log", "--screen-mode=full"}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) }, SetupRepo: func(shell *Shell) { shell.CreateFile("my-file.txt", "myfile content") shell.CreateFile("my-other-file.rb", "my-other-file content") shell.CreateNCommitsWithRandomMessages(60) shell.NewBranch("feature/demo") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("feature/demo", "origin/feature/demo") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Git bisect") t.Wait(1000) markCommitAsBad := func() { t.Views().Commits(). Press(keys.Commits.ViewBisectOptions) t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as bad`)).Confirm() } markCommitAsGood := func() { t.Views().Commits(). Press(keys.Commits.ViewBisectOptions) t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm() } t.Views().Commits(). IsFocused(). Tap(func() { markCommitAsBad() t.Views().Information().Content(Contains("Bisecting")) }). SelectedLine(Contains("<-- bad")). NavigateToLine(Contains("Add TypeScript types to User module")). Tap(markCommitAsGood). SelectedLine(Contains("Add loading indicators to improve UX").Contains("<-- current")). Tap(markCommitAsBad). SelectedLine(Contains("Fix broken links on the help page").Contains("<-- current")). Tap(markCommitAsGood). SelectedLine(Contains("Add end-to-end tests for checkout flow").Contains("<-- current")). Tap(markCommitAsBad). Tap(func() { t.Wait(2000) t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s).*Do you want to reset")).Confirm() }). SetCaptionPrefix("Inspect problematic commit"). Wait(500). Press(keys.Universal.PrevScreenMode). IsFocused(). Content(Contains("Add end-to-end tests for checkout flow")). Wait(500). PressEnter() t.Views().Information().Content(DoesNotContain("Bisecting")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/cherry_pick.go000066400000000000000000000050301500612110400237510ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cherry pick", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(50) shell. EmptyCommit("Fix bug in timezone conversion."). NewBranch("hotfix/fix-bug"). NewBranch("feature/user-module"). Checkout("hotfix/fix-bug"). EmptyCommit("Integrate support for markdown in user posts"). EmptyCommit("Remove unused code and libraries"). Checkout("feature/user-module"). EmptyCommit("Handle session timeout gracefully"). EmptyCommit("Add Webpack for asset bundling"). Checkout("hotfix/fix-bug") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Cherry pick commits from another branch") t.Wait(1000) t.Views().Branches(). Focus(). Lines( Contains("hotfix/fix-bug"), Contains("feature/user-module"), Contains("master"), ). SelectNextItem(). Wait(300). PressEnter() t.Views().SubCommits(). IsFocused(). TopLines( Contains("Add Webpack for asset bundling").IsSelected(), Contains("Handle session timeout gracefully"), Contains("Fix bug in timezone conversion."), ). Press(keys.Commits.CherryPickCopy). Tap(func() { t.Views().Information().Content(Contains("1 commit copied")) }). SelectNextItem(). Press(keys.Commits.CherryPickCopy) t.Views().Information().Content(Contains("2 commits copied")) t.Views().Commits(). Focus(). TopLines( Contains("Remove unused code and libraries").IsSelected(), Contains("Integrate support for markdown in user posts"), Contains("Fix bug in timezone conversion."), ). Press(keys.Commits.PasteCommits). Tap(func() { t.Wait(1000) t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() }). TopLines( Contains("Add Webpack for asset bundling"), Contains("Handle session timeout gracefully"), Contains("Remove unused code and libraries"), Contains("Integrate support for markdown in user posts"), Contains("Fix bug in timezone conversion."), ). Tap(func() { t.Views().Information().Content(DoesNotContain("commits copied")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/commit_and_push.go000066400000000000000000000024751500612110400246320ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitAndPush = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Make a commit and push", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) }, SetupRepo: func(shell *Shell) { shell.CreateFile("my-file.txt", "myfile content") shell.CreateFile("my-other-file.rb", "my-other-file content") shell.CreateNCommitsWithRandomMessages(30) shell.NewBranch("feature/demo") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("feature/demo", "origin/feature/demo") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Stage a file") t.Wait(1000) t.Views().Files(). IsFocused(). PressPrimaryAction(). SetCaptionPrefix("Commit our changes"). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Type("my commit summary"). SwitchToDescription(). Type("my commit description"). SwitchToSummary(). Confirm() t.Views().Commits(). TopLines( Contains("my commit summary"), ) t.SetCaptionPrefix("Push to the remote") t.Views().Files(). IsFocused(). Press(keys.Universal.Push) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/commit_graph.go000066400000000000000000000027071500612110400241300ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CommitGraph = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Show commit graph", ExtraCmdArgs: []string{"log", "--screen-mode=full"}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) setGeneratedAuthorColours(config) }, SetupRepo: func(shell *Shell) { shell.CreateRepoHistory() }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("View commit log") t.Wait(1000) t.Views().Commits(). IsFocused(). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100). SelectNextItem(). Wait(100) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/custom_command.go000066400000000000000000000036461500612110400244720ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var customCommandContent = ` customCommands: - key: 'a' command: 'git checkout {{.Form.Branch}}' context: 'localBranches' prompts: - type: 'input' title: 'Enter a branch name to checkout:' key: 'Branch' suggestions: preset: 'branches' ` var CustomCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Invoke a custom command", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(cfg *config.AppConfig) { setDefaultDemoConfig(cfg) cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "a", Context: "localBranches", Command: `git checkout {{.Form.Branch}}`, Prompts: []config.CustomCommandPrompt{ { Key: "Branch", Type: "input", Title: "Enter a branch name to checkout", Suggestions: config.CustomCommandSuggestions{ Preset: "branches", }, }, }, }, } }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(30) shell.NewBranch("feature/user-authentication") shell.NewBranch("feature/payment-processing") shell.NewBranch("feature/search-functionality") shell.NewBranch("feature/mobile-responsive") shell.EmptyCommit("Make mobile response") shell.NewBranch("bugfix/fix-login-issue") shell.HardReset("HEAD~1") shell.NewBranch("bugfix/fix-crash-bug") shell.CreateFile("custom_commands_example.yml", customCommandContent) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Invoke a custom command") t.Wait(1500) t.Views().Branches(). Focus(). Wait(500). Press("a"). Tap(func() { t.Wait(500) t.ExpectPopup().Prompt(). Title(Equals("Enter a branch name to checkout")). Type("mobile"). ConfirmFirstSuggestion() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/custom_patch.go000066400000000000000000000040441500612110400241440ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var usersFileContent = `package main import "fmt" func main() { // TODO: verify that this actually works fmt.Println("hello world") } ` var CustomPatch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Remove a line from an old commit", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(cfg *config.AppConfig) { setDefaultDemoConfig(cfg) }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(30) shell.NewBranch("feature/user-authentication") shell.EmptyCommit("Add user authentication feature") shell.CreateFileAndAdd("src/users.go", "package main\n") shell.Commit("Fix local session storage") shell.CreateFile("src/authentication.go", "package main") shell.CreateFile("src/session.go", "package main") shell.UpdateFileAndAdd("src/users.go", usersFileContent) shell.EmptyCommit("Stop using shims") shell.UpdateFileAndAdd("src/authentication.go", "package authentication") shell.UpdateFileAndAdd("src/session.go", "package session") shell.Commit("Enhance user authentication feature") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Remove a line from an old commit") t.Wait(1000) t.Views().Commits(). Focus(). NavigateToLine(Contains("Stop using shims")). Wait(1000). PressEnter(). Tap(func() { t.Views().CommitFiles(). IsFocused(). NavigateToLine(Contains("users.go")). Wait(1000). PressEnter(). Tap(func() { t.Views().PatchBuilding(). IsFocused(). NavigateToLine(Contains("TODO")). Wait(500). PressPrimaryAction(). PressEscape() }). Press(keys.Universal.CreatePatchOptionsMenu). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Patch options")). Select(Contains("Remove patch from original commit")). Wait(500). Confirm() }). PressEscape() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/diff_commits.go000066400000000000000000000021711500612110400241150ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiffCommits = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Diff two commits", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) config.GetUserConfig().Gui.ShowFileTree = false config.GetUserConfig().Gui.ShowCommandLog = false }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(50) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Compare two commits") t.Wait(1000) t.Views().Commits(). Focus(). NavigateToLine(Contains("Replace deprecated lifecycle methods in React components")). Wait(1000). Press(keys.Universal.DiffingMenu). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Diffing")). TopLines( MatchesRegexp(`Diff .*`), ). Wait(500). Confirm() }). NavigateToLine(Contains("Move constants to a separate config file")). Wait(1000). PressEnter() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/filter.go000066400000000000000000000067401500612110400227450ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Filter = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filter branches", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(30) shell.NewBranch("feature/user-authentication") shell.NewBranch("feature/payment-processing") shell.NewBranch("feature/search-functionality") shell.NewBranch("feature/mobile-responsive") shell.NewBranch("bugfix/fix-login-issue") shell.NewBranch("bugfix/fix-crash-bug") shell.NewBranch("bugfix/fix-validation-error") shell.NewBranch("refactor/improve-performance") shell.NewBranch("refactor/code-cleanup") shell.NewBranch("refactor/extract-method") shell.NewBranch("docs/update-readme") shell.NewBranch("docs/add-user-guide") shell.NewBranch("docs/api-documentation") shell.NewBranch("experiment/new-feature-idea") shell.NewBranch("experiment/try-new-library") shell.NewBranch("chore/update-dependencies") shell.NewBranch("chore/add-test-cases") shell.NewBranch("chore/migrate-database") shell.NewBranch("hotfix/critical-bug") shell.NewBranch("hotfix/security-patch") shell.NewBranch("feature/social-media-integration") shell.NewBranch("feature/email-notifications") shell.NewBranch("feature/admin-panel") shell.NewBranch("feature/analytics-dashboard") shell.NewBranch("bugfix/fix-registration-flow") shell.NewBranch("bugfix/fix-payment-bug") shell.NewBranch("refactor/improve-error-handling") shell.NewBranch("refactor/optimize-database-queries") shell.NewBranch("docs/improve-tutorials") shell.NewBranch("docs/add-faq-section") shell.NewBranch("experiment/try-alternative-algorithm") shell.NewBranch("experiment/implement-design-concept") shell.NewBranch("chore/update-documentation") shell.NewBranch("chore/improve-test-coverage") shell.NewBranch("chore/cleanup-codebase") shell.NewBranch("hotfix/critical-security-vulnerability") shell.NewBranch("hotfix/fix-production-issue") shell.NewBranch("feature/integrate-third-party-api") shell.NewBranch("feature/image-upload-functionality") shell.NewBranch("feature/localization-support") shell.NewBranch("feature/chat-feature") shell.NewBranch("bugfix/fix-broken-link") shell.NewBranch("bugfix/fix-css-styling") shell.NewBranch("refactor/improve-logging") shell.NewBranch("refactor/extract-reusable-component") shell.NewBranch("docs/add-changelog") shell.NewBranch("docs/update-api-reference") shell.NewBranch("experiment/implement-new-design") shell.NewBranch("experiment/try-different-architecture") shell.NewBranch("chore/clean-up-git-history") shell.NewBranch("chore/update-environment-configuration") shell.CreateFileAndAdd("env_config.rb", "EnvConfig.call(false)\n") shell.Commit("Update env config") shell.CreateFileAndAdd("env_config.rb", "# Turns out we need to pass true for this to work\nEnvConfig.call(true)\n") shell.Commit("Fix env config issue") shell.Checkout("docs/add-faq-section") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Fuzzy filter branches") t.Wait(1000) t.Views().Branches(). Focus(). Wait(500). Press(keys.Universal.StartSearch). Tap(func() { t.Wait(500) t.ExpectSearch().Type("environ").Confirm() }). Wait(500). PressEnter() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/interactive_rebase.go000066400000000000000000000032171500612110400253120ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var InteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Interactive rebase", ExtraCmdArgs: []string{"log", "--screen-mode=full"}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) }, SetupRepo: func(shell *Shell) { shell.CreateRepoHistory() shell.NewBranch("feature/demo") shell.CreateNCommitsWithRandomMessages(10) shell.CloneIntoRemote("origin") shell.SetBranchUpstream("feature/demo", "origin/feature/demo") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Interactive rebase") t.Wait(1000) t.Views().Commits(). IsFocused(). Press(keys.Commits.StartInteractiveRebase). PressFast(keys.Universal.RangeSelectDown). PressFast(keys.Universal.RangeSelectDown). Press(keys.Commits.MarkCommitAsFixup). PressFast(keys.Commits.MoveDownCommit). PressFast(keys.Commits.MoveDownCommit). Delay(). SelectNextItem(). SelectNextItem(). Press(keys.Universal.Remove). SelectNextItem(). Press(keys.Commits.SquashDown). Press(keys.Universal.CreateRebaseOptionsMenu). Tap(func() { t.ExpectPopup().Menu(). Title(Contains("Rebase options")). Select(Contains("continue")). Confirm() }). SetCaptionPrefix("Push to remote"). Press(keys.Universal.NextScreenMode). Press(keys.Universal.Push). Tap(func() { t.ExpectPopup().Confirmation(). Title(Contains("Force push")). Content(AnyString()). Confirm() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/nuke_working_tree.go000066400000000000000000000024131500612110400251720ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NukeWorkingTree = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Nuke the working tree", ExtraCmdArgs: []string{"status", "--screen-mode=full"}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) config.GetUserConfig().Gui.AnimateExplosion = true }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("blah") shell.CreateFile("controllers/red_controller.rb", "") shell.CreateFile("controllers/green_controller.rb", "") shell.CreateFileAndAdd("controllers/blue_controller.rb", "") shell.CreateFile("controllers/README.md", "") shell.CreateFileAndAdd("views/helpers/list.rb", "") shell.CreateFile("views/helpers/sort.rb", "") shell.CreateFileAndAdd("views/users_view.rb", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Nuke the working tree") t.Wait(1000) t.Views().Files(). IsFocused(). Wait(1000). Press(keys.Files.ViewResetOptions). Tap(func() { t.Wait(1000) t.ExpectPopup().Menu(). Title(Equals("")). Select(Contains("Nuke working tree")). Confirm() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/rebase_onto.go000066400000000000000000000045711500612110400237600ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RebaseOnto = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase with '--onto' flag. We start with a feature branch on the develop branch that we want to rebase onto the master branch", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(60) shell.NewBranch("develop") shell.SetAuthor("Joe Blow", "joeblow@gmail.com") shell.RandomChangeCommit("Develop commit 1") shell.RandomChangeCommit("Develop commit 2") shell.RandomChangeCommit("Develop commit 3") shell.SetAuthor("Jesse Duffield", "jesseduffield@gmail.com") shell.NewBranch("feature/demo") shell.RandomChangeCommit("Feature commit 1") shell.RandomChangeCommit("Feature commit 2") shell.RandomChangeCommit("Feature commit 3") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("feature/demo", "origin/feature/demo") shell.SetBranchUpstream("develop", "origin/develop") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Rebase from marked base commit") t.Wait(1000) // first we focus the commits view, then expand to show the branches against each commit // Then we go back to normal value, mark the last develop branch commit as the marked commit // Then go to the branches view and press 'r' on the master branch to rebase onto it // then we force push our changes. t.Views().Commits(). Focus(). Press(keys.Universal.PrevScreenMode). Wait(500). NavigateToLine(Contains("Develop commit 3")). Wait(500). Press(keys.Commits.MarkCommitAsBaseForRebase). Wait(1000). Press(keys.Universal.NextScreenMode). Wait(500) t.Views().Branches(). Focus(). Wait(500). NavigateToLine(Contains("master")). Wait(500). Press(keys.Branches.RebaseBranch). Tap(func() { t.ExpectPopup().Menu(). Title(Contains("Rebase 'feature/demo' from marked base")). Select(Contains("Simple rebase")). Confirm() }). Wait(1000). Press(keys.Universal.Push). Tap(func() { t.ExpectPopup().Confirmation(). Title(Contains("Force push")). Content(AnyString()). Wait(500). Confirm() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/shared.go000066400000000000000000000011471500612110400227220ustar00rootroot00000000000000package demo import "github.com/jesseduffield/lazygit/pkg/config" // Gives us nicer colours when we generate a git repo history with `shell.CreateRepoHistory()` func setGeneratedAuthorColours(config *config.AppConfig) { config.GetUserConfig().Gui.AuthorColors = map[string]string{ "Fredrica Greenhill": "#fb5aa3", "Oscar Reuenthal": "#86c82f", "Paul Oberstein": "#ffd500", "Siegfried Kircheis": "#fe7e11", "Yang Wen-li": "#8e3ccb", } } func setDefaultDemoConfig(config *config.AppConfig) { // demos look much nicer with icons shown config.GetUserConfig().Gui.NerdFontsVersion = "3" } lazygit-0.50.0+ds1/pkg/integration/tests/demo/stage_lines.go000066400000000000000000000033671500612110400237570ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var originalFile = `# Lazygit Simple terminal UI for git commands ![demo](https://user-images.gh.com/demo.gif) ## Installation ### Homebrew ` var updatedFile = `# Lazygit Simple terminal UI for git (Not too simple though) ![demo](https://user-images.gh.com/demo.gif) ## Installation ### Homebrew Just do brew install lazygit and bada bing bada boom you have begun on the path of laziness. ` var StageLines = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stage individual lines", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) config.GetUserConfig().Gui.ShowFileTree = false config.GetUserConfig().Gui.ShowCommandLog = false }, SetupRepo: func(shell *Shell) { shell.NewBranch("docs-fix") shell.CreateNCommitsWithRandomMessages(30) shell.CreateFileAndAdd("docs/README.md", originalFile) shell.Commit("Update docs/README") shell.UpdateFile("docs/README.md", updatedFile) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Stage individual lines") t.Wait(1000) t.Views().Files(). IsFocused(). PressEnter() t.Views().Staging(). IsFocused(). Press(keys.Universal.ToggleRangeSelect). PressFast(keys.Universal.NextItem). PressFast(keys.Universal.NextItem). Wait(500). PressPrimaryAction(). Wait(500). PressEscape() t.Views().Files(). IsFocused(). Press(keys.Files.CommitChanges). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Type("Update tagline"). Confirm() }) t.Views().Commits(). Focus() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/undo.go000066400000000000000000000025571500612110400224270ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Undo = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Undo", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(config *config.AppConfig) { setDefaultDemoConfig(config) }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(30) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Undo commands") t.Wait(1000) confirmCommitDrop := func() { t.ExpectPopup().Confirmation(). Title(Equals("Drop commit")). Content(Equals("Are you sure you want to drop the selected commit(s)?")). Wait(500). Confirm() } confirmUndo := func() { t.ExpectPopup().Confirmation(). Title(Equals("Undo")). Content(MatchesRegexp(`Are you sure you want to hard reset to '.*'\? An auto-stash will be performed if necessary\.`)). Wait(500). Confirm() } t.Views().Commits().Focus(). SetCaptionPrefix("Drop two commits"). Wait(1000). Press(keys.Universal.Remove). Tap(confirmCommitDrop). Press(keys.Universal.Remove). Tap(confirmCommitDrop). SetCaptionPrefix("Undo the drops"). Wait(1000). Press(keys.Universal.Undo). Tap(confirmUndo). Press(keys.Universal.Undo). Tap(confirmUndo) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/demo/worktree_create_from_branches.go000066400000000000000000000033211500612110400275250ustar00rootroot00000000000000package demo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var WorktreeCreateFromBranches = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a worktree from the branches view", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(cfg *config.AppConfig) { setDefaultDemoConfig(cfg) }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(30) shell.NewBranch("feature/user-authentication") shell.EmptyCommit("Add user authentication feature") shell.EmptyCommit("Fix local session storage") shell.CreateFile("src/authentication.go", "package main") shell.CreateFile("src/shims.go", "package main") shell.CreateFile("src/session.go", "package main") shell.EmptyCommit("Stop using shims") shell.UpdateFile("src/authentication.go", "package authentication") shell.UpdateFileAndAdd("src/shims.go", "// removing for now") shell.UpdateFile("src/session.go", "package session") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.SetCaptionPrefix("Create a worktree from a branch") t.Wait(1000) t.Views().Branches(). Focus(). NavigateToLine(Contains("master")). Wait(500). Press(keys.Worktrees.ViewWorktreeOptions). Tap(func() { t.Wait(500) t.ExpectPopup().Menu(). Title(Equals("Worktree")). Select(Contains("Create worktree from master").DoesNotContain("detached")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). Type("../hotfix"). Confirm() t.ExpectPopup().Prompt(). Title(Contains("New branch name")). Type("hotfix/db-on-fire"). Confirm() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/diff/000077500000000000000000000000001500612110400211065ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/diff/copy_to_clipboard.go000066400000000000000000000116041500612110400251320ustar00rootroot00000000000000package diff import ( "os" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // note: this is required to simulate the clipboard during CI func expectClipboard(t *TestDriver, matcher *TextMatcher) { defer t.Shell().DeleteFile("clipboard") t.FileSystem().FileContent("clipboard", matcher) } var CopyToClipboard = NewIntegrationTest(NewIntegrationTestArgs{ Description: "The copy menu allows to copy name and diff of selected/all files", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" }, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "1st line\n") shell.Commit("1") shell.UpdateFileAndAdd("dir/file1", "1st line\n2nd line\n") shell.CreateFileAndAdd("dir/file2", "file2\n") shell.Commit("2") shell.UpdateFileAndAdd("dir/file1", "1st line\n2nd line\n3rd line\n") shell.Commit("3") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("3").IsSelected(), Contains("2"), Contains("1"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("dir").IsSelected(), Contains("file1"), Contains("file2"), ). NavigateToLine(Contains("file1")). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("File name")). Confirm(). Tap(func() { t.ExpectToast(Equals("File name copied to clipboard")) expectClipboard(t, Equals("file1")) }) }). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Relative path")). Confirm(). Tap(func() { t.ExpectToast(Equals("File path copied to clipboard")) expectClipboard(t, Equals("dir/file1")) }) }). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Absolute path")). Confirm(). Tap(func() { t.ExpectToast(Equals("File path copied to clipboard")) repoDir, _ := os.Getwd() // On windows the following path would have backslashes, but we don't run integration tests on windows yet. expectClipboard(t, Equals(repoDir+"/dir/file1")) }) }). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of selected file")). Confirm(). Tap(func() { t.ExpectToast(Equals("File diff copied to clipboard")) expectClipboard(t, Contains("diff --git a/dir/file1 b/dir/file1").Contains("+2nd line").DoesNotContain("+1st line"). DoesNotContain("diff --git a/dir/file2 b/dir/file2").DoesNotContain("+file2")) }) }). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of all files")). Confirm(). Tap(func() { t.ExpectToast(Equals("All files diff copied to clipboard")) expectClipboard(t, Contains("diff --git a/dir/file1 b/dir/file1").Contains("+2nd line").DoesNotContain("+1st line"). Contains("diff --git a/dir/file2 b/dir/file2").Contains("+file2")) }) }). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Content of selected file")). Confirm(). Tap(func() { t.ExpectToast(Equals("File content copied to clipboard")) expectClipboard(t, Equals("1st line\n2nd line\n")) }) }) t.Views().Commits(). Focus(). // Select commits 1 and 2 Press(keys.Universal.RangeSelectDown). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("dir").IsSelected(), Contains("file1"), Contains("file2"), ). NavigateToLine(Contains("file1")). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of selected file")). Confirm(). Tap(func() { t.ExpectToast(Equals("File diff copied to clipboard")) expectClipboard(t, Contains("diff --git a/dir/file1 b/dir/file1").Contains("+1st line").Contains("+2nd line")) }) }). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Content of selected file")). Confirm(). Tap(func() { t.ExpectToast(Equals("File content copied to clipboard")) expectClipboard(t, Equals("1st line\n2nd line\n")) }) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/diff/diff.go000066400000000000000000000040461500612110400223510ustar00rootroot00000000000000package diff import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Diff = NewIntegrationTest(NewIntegrationTestArgs{ Description: "View the diff of two branches, then view the reverse diff", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-a") shell.CreateFileAndAdd("file1", "first line") shell.Commit("first commit") shell.NewBranch("branch-b") shell.UpdateFileAndAdd("file1", "first line\nsecond line") shell.Commit("update") shell.Checkout("branch-a") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). TopLines( Contains("branch-a"), Contains("branch-b"), ). Press(keys.Universal.DiffingMenu) t.ExpectPopup().Menu().Title(Equals("Diffing")).Select(Contains(`Diff branch-a`)).Confirm() t.Views().Branches(). IsFocused(). Tap(func() { t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-a")) }). SelectNextItem(). Tap(func() { t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-b")) t.Views().Main().Content(Contains("+second line")) }). PressEnter() t.Views().SubCommits(). IsFocused(). SelectedLine(Contains("update")). Tap(func() { t.Views().Main().Content(Contains("+second line")) }). PressEnter() t.Views().CommitFiles(). IsFocused(). SelectedLine(Contains("file1")). Tap(func() { t.Views().Main().Content(Contains("+second line")) }). PressEscape() t.Views().SubCommits().PressEscape() t.Views().Branches(). IsFocused(). Press(keys.Universal.DiffingMenu) t.ExpectPopup().Menu().Title(Equals("Diffing")).Select(Contains("Reverse diff direction")).Confirm() t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-b -R")) t.Views().Main().Content(Contains("-second line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/diff/diff_and_apply_patch.go000066400000000000000000000044441500612110400255610ustar00rootroot00000000000000package diff import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiffAndApplyPatch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a patch from the diff between two branches and apply the patch.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-a") shell.CreateFileAndAdd("file1", "first line\n") shell.Commit("first commit") shell.NewBranch("branch-b") shell.UpdateFileAndAdd("file1", "first line\nsecond line\n") shell.Commit("update") shell.Checkout("branch-a") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("branch-a"), Contains("branch-b"), ). Press(keys.Universal.DiffingMenu) t.ExpectPopup().Menu().Title(Equals("Diffing")).Select(Equals("Diff branch-a")).Confirm() t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-a")) t.Views().Branches(). IsFocused(). SelectNextItem(). Tap(func() { t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-b")) t.Views().Main().Content(Contains("+second line")) }). PressEnter() t.Views().SubCommits(). IsFocused(). SelectedLine(Contains("update")). Tap(func() { t.Views().Main().Content(Contains("+second line")) }). PressEnter() t.Views().CommitFiles(). IsFocused(). SelectedLine(Contains("file1")). Tap(func() { t.Views().Main().Content(Contains("+second line")) }). PressPrimaryAction(). // add the file to the patch Press(keys.Universal.DiffingMenu). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Diffing")).Select(Contains("Exit diff mode")).Confirm() t.Views().Information().Content(Contains("Building patch")) }). Press(keys.Universal.CreatePatchOptionsMenu) // adding the regex '$' here to distinguish the menu item from the 'Apply patch in reverse' item t.ExpectPopup().Menu().Title(Equals("Patch options")).Select(MatchesRegexp("Apply patch$")).Confirm() t.Views().Files(). Focus(). SelectedLine(Contains("file1")) t.Views().Main().Content(Contains("+second line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/diff/diff_commits.go000066400000000000000000000032421500612110400241010ustar00rootroot00000000000000package diff import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiffCommits = NewIntegrationTest(NewIntegrationTestArgs{ Description: "View the diff between two commits", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "first line\n") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "first line\nsecond line\n") shell.Commit("second commit") shell.UpdateFileAndAdd("file1", "first line\nsecond line\nthird line\n") shell.Commit("third commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("third commit").IsSelected(), Contains("second commit"), Contains("first commit"), ). Press(keys.Universal.DiffingMenu). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Diffing")).Select(MatchesRegexp(`Diff \w+`)).Confirm() t.Views().Information().Content(Contains("Showing output for: git diff")) }). SelectNextItem(). SelectNextItem(). SelectedLine(Contains("first commit")). Tap(func() { t.Views().Main().Content(Contains("-second line\n-third line")) }). Press(keys.Universal.DiffingMenu). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Diffing")).Select(Contains("Reverse diff direction")).Confirm() t.Views().Main().Content(Contains("+second line\n+third line")) }). PressEnter() t.Views().CommitFiles(). IsFocused(). SelectedLine(Contains("file1")) t.Views().Main().Content(Contains("+second line\n+third line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/diff/diff_non_sticky_range.go000066400000000000000000000025741500612110400257710ustar00rootroot00000000000000package diff import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiffNonStickyRange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "View the combined diff of multiple commits using a range selection", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFileAndAdd("file1", "first line\n") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "first line\nsecond line\n") shell.Commit("second commit") shell.UpdateFileAndAdd("file1", "first line\nsecond line\nthird line\n") shell.Commit("third commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("third commit").IsSelected(), Contains("second commit"), Contains("first commit"), Contains("initial commit"), ). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Tap(func() { t.Views().Main().Content(Contains("Showing diff for range "). Contains("+first line\n+second line\n+third line")) }). PressEnter() t.Views().CommitFiles(). IsFocused(). SelectedLine(Contains("file1")) t.Views().Main().Content(Contains("+first line\n+second line\n+third line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/diff/ignore_whitespace.go000066400000000000000000000032611500612110400251360ustar00rootroot00000000000000package diff import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ( initialFileContent = "first-line\nold-second-line\nthird-line\n" // We're indenting each line and modifying the second line updatedFileContent = " first-line\n new-second-line\n third-line\n" ) var IgnoreWhitespace = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Toggle whitespace in the diff", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", initialFileContent) shell.Commit("initial commit") shell.UpdateFile("myfile", updatedFileContent) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Main().ContainsLines( Contains(`-first-line`), Contains(`-old-second-line`), Contains(`-third-line`), Contains(`+ first-line`), Contains(`+ new-second-line`), Contains(`+ third-line`), ) t.Views().Files(). IsFocused(). Press(keys.Universal.ToggleWhitespaceInDiffView) // lines with only whitespace changes are ignored (first and third lines) t.Views().Main().ContainsLines( Contains(` first-line`), Contains(`-old-second-line`), Contains(`+ new-second-line`), Contains(` third-line`), ) // when toggling again it goes back to showing whitespace t.Views().Files(). IsFocused(). Press(keys.Universal.ToggleWhitespaceInDiffView) t.Views().Main().ContainsLines( Contains(`-first-line`), Contains(`-old-second-line`), Contains(`-third-line`), Contains(`+ first-line`), Contains(`+ new-second-line`), Contains(`+ third-line`), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/diff/rename_similarity_threshold_change.go000066400000000000000000000030331500612110400305320ustar00rootroot00000000000000package diff import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RenameSimilarityThresholdChange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Change the rename similarity threshold while in the commits panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("original", "one\ntwo\nthree\nfour\nfive\n") shell.Commit("add original") shell.DeleteFileAndAdd("original") shell.CreateFileAndAdd("renamed", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") shell.Commit("change name and contents") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus() t.Views().Main(). ContainsLines( Contains("2 files changed, 10 insertions(+), 5 deletions(-)"), ) t.Views().Commits(). Press(keys.Universal.DecreaseRenameSimilarityThreshold). Tap(func() { t.ExpectToast(Equals("Changed rename similarity threshold to 45%")) }) t.Views().Main(). ContainsLines( Contains("original => renamed"), Contains("1 file changed, 5 insertions(+)"), ) t.Views().Commits(). Press(keys.Universal.FocusMainView) t.Views().Main(). Press(keys.Universal.IncreaseRenameSimilarityThreshold). Tap(func() { t.ExpectToast(Equals("Changed rename similarity threshold to 50%")) }). ContainsLines( Contains("2 files changed, 10 insertions(+), 5 deletions(-)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/000077500000000000000000000000001500612110400211155ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/file/collapse_expand.go000066400000000000000000000021731500612110400246100ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CollapseExpand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Collapsing and expanding all files in the file tree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFile("dir/file-one", "original content\n") shell.CreateDir("dir2") shell.CreateFile("dir2/file-two", "original content\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir"), Equals(" ?? file-one"), Equals(" ▼ dir2"), Equals(" ?? file-two"), ) t.Views().Files(). Press(keys.Files.CollapseAll). Lines( Equals("▶ /"), ) t.Views().Files(). Press(keys.Files.ExpandAll). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir"), Equals(" ?? file-one"), Equals(" ▼ dir2"), Equals(" ?? file-two"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/copy_menu.go000066400000000000000000000142041500612110400234430ustar00rootroot00000000000000package file import ( "os" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // note: this is required to simulate the clipboard during CI func expectClipboard(t *TestDriver, matcher *TextMatcher) { defer t.Shell().DeleteFile("clipboard") t.FileSystem().FileContent("clipboard", matcher) } var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{ Description: "The copy menu allows to copy name and diff of selected/all files", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" }, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { // Disabled item t.Views().Files(). IsEmpty(). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("File name")). Tooltip(Equals("Disabled: No item selected")). Confirm(). Tap(func() { t.ExpectToast(Equals("Disabled: No item selected")) }). Cancel() }) t.Shell(). CreateDir("dir"). CreateFile("dir/1-unstaged_file", "unstaged content") // Empty content (new file) t.Views().Files(). Press(keys.Universal.Refresh). Lines( Contains("dir").IsSelected(), Contains("unstaged_file"), ). SelectNextItem(). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of selected file")). Tooltip(Contains("Disabled: Nothing to copy")). Confirm(). Tap(func() { t.ExpectToast(Equals("Disabled: Nothing to copy")) }). Cancel() }). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of all files")). Tooltip(Contains("Disabled: Nothing to copy")). Confirm(). Tap(func() { t.ExpectToast(Equals("Disabled: Nothing to copy")) }). Cancel() }) t.Shell(). GitAdd("dir/1-unstaged_file"). Commit("commit-unstaged"). UpdateFile("dir/1-unstaged_file", "unstaged content (new)"). CreateFileAndAdd("dir/2-staged_file", "staged content"). Commit("commit-staged"). UpdateFile("dir/2-staged_file", "staged content (new)"). GitAdd("dir/2-staged_file") // Copy file name t.Views().Files(). Press(keys.Universal.Refresh). Lines( Contains("dir"), Contains("unstaged_file").IsSelected(), Contains("staged_file"), ). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("File name")). Confirm() t.ExpectToast(Equals("File name copied to clipboard")) expectClipboard(t, Equals("1-unstaged_file")) }) // Copy relative file path t.Views().Files(). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Relative path")). Confirm() t.ExpectToast(Equals("File path copied to clipboard")) expectClipboard(t, Equals("dir/1-unstaged_file")) }) // Copy absolute file path t.Views().Files(). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Absolute path")). Confirm() t.ExpectToast(Equals("File path copied to clipboard")) repoDir, _ := os.Getwd() // On windows the following path would have backslashes, but we don't run integration tests on windows yet. expectClipboard(t, Equals(repoDir+"/dir/1-unstaged_file")) }) // Selected path diff on a single (unstaged) file t.Views().Files(). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of selected file")). Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")). Confirm() t.ExpectToast(Equals("File diff copied to clipboard")) expectClipboard(t, Contains("+unstaged content (new)")) }) // Selected path diff with staged and unstaged files t.Views().Files(). SelectPreviousItem(). Lines( Contains("dir").IsSelected(), Contains("unstaged_file"), Contains("staged_file"), ). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of selected file")). Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")). Confirm() t.ExpectToast(Equals("File diff copied to clipboard")) expectClipboard(t, Contains("+staged content (new)")) }) // All files diff with staged files t.Views().Files(). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of all files")). Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")). Confirm() t.ExpectToast(Equals("All files diff copied to clipboard")) expectClipboard(t, Contains("+staged content (new)")) }) // All files diff with no staged files t.Views().Files(). SelectNextItem(). SelectNextItem(). Lines( Contains("dir"), Contains("unstaged_file"), Contains("staged_file").IsSelected(), ). Press(keys.Universal.Select). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Copy to clipboard")). Select(Contains("Diff of all files")). Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")). Confirm() t.ExpectToast(Equals("All files diff copied to clipboard")) expectClipboard(t, Contains("+staged content (new)").Contains("+unstaged content (new)")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/dir_with_untracked_file.go000066400000000000000000000021671500612110400263220ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DirWithUntrackedFile = NewIntegrationTest(NewIntegrationTestArgs{ // notably, we currently _don't_ actually see the untracked file in the diff. Not sure how to get around that. Description: "When selecting a directory that contains an untracked file, we should not get an error", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFile("dir/file", "foo") shell.GitAddAll() shell.Commit("first commit") shell.CreateFile("dir/untracked", "bar") shell.UpdateFile("dir/file", "baz") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("first commit"), ) t.Views().Main(). Content(DoesNotContain("error: Could not access")). // we show baz because it's a modified file but we don't show bar because it's untracked // (though it would be cool if we could show that too) Content(Contains("baz")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/discard_all_dir_changes.go000066400000000000000000000142141500612110400262350ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardAllDirChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discarding all changes in a directory", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { // typically we would use more bespoke shell methods here, but I struggled to find a way to do that, // and this is copied over from a legacy integration test which did everything in a big shell script // so I'm just copying it across. shell.CreateDir("dir") // common stuff shell.RunShellCommand(`echo test > dir/both-deleted.txt`) shell.RunShellCommand(`git checkout -b conflict && git add dir/both-deleted.txt`) shell.RunShellCommand(`echo bothmodded > dir/both-modded.txt && git add dir/both-modded.txt`) shell.RunShellCommand(`echo haha > dir/deleted-them.txt && git add dir/deleted-them.txt`) shell.RunShellCommand(`echo haha2 > dir/deleted-us.txt && git add dir/deleted-us.txt`) shell.RunShellCommand(`echo mod > dir/modded.txt && git add dir/modded.txt`) shell.RunShellCommand(`echo mod > dir/modded-staged.txt && git add dir/modded-staged.txt`) shell.RunShellCommand(`echo del > dir/deleted.txt && git add dir/deleted.txt`) shell.RunShellCommand(`echo del > dir/deleted-staged.txt && git add dir/deleted-staged.txt`) shell.RunShellCommand(`echo change-delete > dir/change-delete.txt && git add dir/change-delete.txt`) shell.RunShellCommand(`echo delete-change > dir/delete-change.txt && git add dir/delete-change.txt`) shell.RunShellCommand(`echo double-modded > dir/double-modded.txt && git add dir/double-modded.txt`) shell.RunShellCommand(`echo "renamed\nhaha" > dir/renamed.txt && git add dir/renamed.txt`) shell.RunShellCommand(`git commit -m one`) // stuff on other branch shell.RunShellCommand(`git branch conflict_second && git mv dir/both-deleted.txt dir/added-them-changed-us.txt`) shell.RunShellCommand(`git commit -m "dir/both-deleted.txt renamed in dir/added-them-changed-us.txt"`) shell.RunShellCommand(`echo blah > dir/both-added.txt && git add dir/both-added.txt`) shell.RunShellCommand(`echo mod1 > dir/both-modded.txt && git add dir/both-modded.txt`) shell.RunShellCommand(`rm dir/deleted-them.txt && git add dir/deleted-them.txt`) shell.RunShellCommand(`echo modded > dir/deleted-us.txt && git add dir/deleted-us.txt`) shell.RunShellCommand(`git commit -m "two"`) // stuff on our branch shell.RunShellCommand(`git checkout conflict_second`) shell.RunShellCommand(`git mv dir/both-deleted.txt dir/changed-them-added-us.txt`) shell.RunShellCommand(`git commit -m "both-deleted.txt renamed in dir/changed-them-added-us.txt"`) shell.RunShellCommand(`echo mod2 > dir/both-modded.txt && git add dir/both-modded.txt`) shell.RunShellCommand(`echo blah2 > dir/both-added.txt && git add dir/both-added.txt`) shell.RunShellCommand(`echo modded > dir/deleted-them.txt && git add dir/deleted-them.txt`) shell.RunShellCommand(`rm dir/deleted-us.txt && git add dir/deleted-us.txt`) shell.RunShellCommand(`git commit -m "three"`) shell.RunShellCommand(`git reset --hard conflict_second`) shell.RunCommandExpectError([]string{"git", "merge", "conflict"}) shell.RunShellCommand(`echo "new" > dir/new.txt`) shell.RunShellCommand(`echo "new staged" > dir/new-staged.txt && git add dir/new-staged.txt`) shell.RunShellCommand(`echo mod2 > dir/modded.txt`) shell.RunShellCommand(`echo mod2 > dir/modded-staged.txt && git add dir/modded-staged.txt`) shell.RunShellCommand(`rm dir/deleted.txt`) shell.RunShellCommand(`rm dir/deleted-staged.txt && git add dir/deleted-staged.txt`) shell.RunShellCommand(`echo change-delete2 > dir/change-delete.txt && git add dir/change-delete.txt`) shell.RunShellCommand(`rm dir/change-delete.txt`) shell.RunShellCommand(`rm dir/delete-change.txt && git add dir/delete-change.txt`) shell.RunShellCommand(`echo "changed" > dir/delete-change.txt`) shell.RunShellCommand(`echo "change1" > dir/double-modded.txt && git add dir/double-modded.txt`) shell.RunShellCommand(`echo "change2" > dir/double-modded.txt`) shell.RunShellCommand(`echo before > dir/added-changed.txt && git add dir/added-changed.txt`) shell.RunShellCommand(`echo after > dir/added-changed.txt`) shell.RunShellCommand(`rm dir/renamed.txt && git add dir/renamed.txt`) shell.RunShellCommand(`echo "renamed\nhaha" > dir/renamed2.txt && git add dir/renamed2.txt`) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("dir").IsSelected(), Contains("UA").Contains("added-them-changed-us.txt"), Contains("AA").Contains("both-added.txt"), Contains("DD").Contains("both-deleted.txt"), Contains("UU").Contains("both-modded.txt"), Contains("AU").Contains("changed-them-added-us.txt"), Contains("UD").Contains("deleted-them.txt"), Contains("DU").Contains("deleted-us.txt"), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() }). Tap(func() { t.Common().ContinueOnConflictsResolved("merge") t.ExpectPopup().Confirmation(). Title(Equals("Continue")). Content(Contains("Files have been modified since conflicts were resolved. Auto-stage them and continue?")). Cancel() t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) t.ExpectPopup().Menu(). Title(Equals("Merge options")). Select(Contains("continue")). Confirm() }). Lines( Contains("dir").IsSelected(), Contains(" M").Contains("added-changed.txt"), Contains(" D").Contains("change-delete.txt"), Contains("??").Contains("delete-change.txt"), Contains(" D").Contains("deleted.txt"), Contains(" M").Contains("double-modded.txt"), Contains(" M").Contains("modded.txt"), Contains("??").Contains("new.txt"), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() }). IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/discard_range_select.go000066400000000000000000000056351500612110400256010ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discard a range of files using range select", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("dir2/file-2b", "old content") shell.CreateFileAndAdd("dir3/file-3b", "old content") shell.Commit("first commit") shell.UpdateFile("dir2/file-2b", "new content") shell.UpdateFile("dir3/file-3b", "new content") shell.CreateFile("dir1/file-1a", "") shell.CreateFile("dir1/file-1b", "") shell.CreateFile("dir2/file-2a", "") shell.CreateFile("dir3/file-3a", "") shell.CreateFile("file-a", "") shell.CreateFile("file-b", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir1"), Equals(" ?? file-1a"), Equals(" ?? file-1b"), Equals(" ▼ dir2"), Equals(" ?? file-2a"), Equals(" M file-2b"), Equals(" ▼ dir3"), Equals(" ?? file-3a"), Equals(" M file-3b"), Equals(" ?? file-a"), Equals(" ?? file-b"), ). NavigateToLine(Contains("file-1b")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file-2a")). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-1a"), Equals(" ?? file-1b").IsSelected(), Equals(" ▼ dir2").IsSelected(), Equals(" ?? file-2a").IsSelected(), Equals(" M file-2b"), Equals(" ▼ dir3"), Equals(" ?? file-3a"), Equals(" M file-3b"), Equals(" ?? file-a"), Equals(" ?? file-b"), ). // Discard Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() }). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-1a"), Equals(" ▼ dir3").IsSelected(), Equals(" ?? file-3a"), Equals(" M file-3b"), Equals(" ?? file-a"), Equals(" ?? file-b"), ). // Verify you can discard collapsed directories in range select PressEnter(). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file-a")). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-1a"), Equals(" ▶ dir3").IsSelected(), Equals(" ?? file-a").IsSelected(), Equals(" ?? file-b"), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() }). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-1a"), Equals(" ?? file-b").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/discard_staged_changes.go000066400000000000000000000030661500612110400261010ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardStagedChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discarding staged changes", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("fileToRemove", "original content") shell.CreateFileAndAdd("file2", "original content") shell.Commit("first commit") shell.CreateFile("file3", "original content") shell.UpdateFile("fileToRemove", "new content") shell.UpdateFile("file2", "new content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" M file2"), Equals(" ?? file3"), Equals(" M fileToRemove"), ). NavigateToLine(Contains(`fileToRemove`)). PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" M file2"), Equals(" ?? file3"), Equals(" M fileToRemove").IsSelected(), ). Press(keys.Files.ViewResetOptions) t.ExpectPopup().Menu().Title(Equals("")).Select(Contains("Discard staged changes")).Confirm() // staged file has been removed t.Views().Files(). Lines( Equals("▼ /"), Equals(" M file2"), Equals(" ?? file3").IsSelected(), ) // the file should have the same content that it originally had, given that that was committed already t.FileSystem().FileContent("fileToRemove", Equals("original content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/discard_unstaged_dir_changes.go000066400000000000000000000034131500612110400272760ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardUnstagedDirChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discarding unstaged changes in a directory", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file-one", "original content\n") shell.Commit("first commit") shell.UpdateFileAndAdd("dir/file-one", "original content\nnew content\n") shell.UpdateFile("dir/file-one", "original content\nnew content\neven newer content\n") shell.CreateDir("dir/subdir") shell.CreateFile("dir/subdir/unstaged-file-one", "unstaged file") shell.CreateFile("dir/unstaged-file-two", "unstaged file") shell.CreateFile("unstaged-file-three", "unstaged file") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir"), Equals(" ▼ subdir"), Equals(" ?? unstaged-file-one"), Equals(" MM file-one"), Equals(" ?? unstaged-file-two"), Equals(" ?? unstaged-file-three"), ). SelectNextItem(). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard unstaged changes")). Confirm() }). Lines( Equals("▼ /"), Equals(" ▼ dir").IsSelected(), Equals(" M file-one"), // this guy remains untouched because it wasn't inside the 'dir' directory Equals(" ?? unstaged-file-three"), ) t.FileSystem().FileContent("dir/file-one", Equals("original content\nnew content\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/discard_unstaged_file_changes.go000066400000000000000000000035441500612110400274440ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardUnstagedFileChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discarding unstaged changes in a file", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file-one", "original content\n") shell.Commit("first commit") shell.UpdateFileAndAdd("file-one", "original content\nnew content\n") shell.UpdateFile("file-one", "original content\nnew content\neven newer content\n") shell.CreateFileAndAdd("file-two", "original content\n") shell.UpdateFile("file-two", "original content\nnew content\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" MM file-one"), Equals(" AM file-two"), ). SelectNextItem(). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard unstaged changes")). Confirm() }). Lines( Equals("▼ /"), Equals(" M file-one").IsSelected(), Equals(" AM file-two"), ). SelectNextItem(). Lines( Equals("▼ /"), Equals(" M file-one"), Equals(" AM file-two").IsSelected(), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard unstaged changes")). Confirm() }). Lines( Equals("▼ /"), Equals(" M file-one"), Equals(" A file-two").IsSelected(), ) t.FileSystem().FileContent("file-one", Equals("original content\nnew content\n")) t.FileSystem().FileContent("file-two", Equals("original content\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/discard_unstaged_range_select.go000066400000000000000000000044111500612110400274620ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardUnstagedRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discard unstaged changed in a range of files using range select", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("dir2/file-d", "old content") shell.Commit("first commit") shell.UpdateFile("dir2/file-d", "new content") shell.CreateFile("dir1/file-a", "") shell.CreateFile("dir1/file-b", "") shell.CreateFileAndAdd("dir2/file-c", "") shell.CreateFile("file-e", "") shell.CreateFile("file-f", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" ?? file-b"), Equals(" ▼ dir2"), Equals(" A file-c"), Equals(" M file-d"), Equals(" ?? file-e"), Equals(" ?? file-f"), ). NavigateToLine(Contains("file-b")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file-c")). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" ?? file-b").IsSelected(), Equals(" ▼ dir2").IsSelected(), Equals(" A file-c").IsSelected(), Equals(" M file-d"), Equals(" ?? file-e"), Equals(" ?? file-f"), ). // Discard Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard unstaged changes")). Confirm() }). // file-b is gone because it was selected and contained no staged changes. // file-c is still there because it contained no unstaged changes // file-d is gone because it was selected via dir2 and contained only unstaged changes Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" ▼ dir2"), // Re-selecting file-c because it's where the selected line index // was before performing the action. Equals(" A file-c").IsSelected(), Equals(" ?? file-e"), Equals(" ?? file-f"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/discard_various_changes.go000066400000000000000000000042221500612110400263150ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardVariousChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discarding all possible permutations of changed files", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { createAllPossiblePermutationsOfChangedFiles(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { type statusFile struct { status string label string } t.Views().Files(). IsFocused(). TopLines( Equals("▼ /").IsSelected(), ) discardOneByOne := func(files []statusFile) { for _, file := range files { t.Views().Files(). IsFocused(). NavigateToLine(Contains(file.status + " " + file.label)). Press(keys.Universal.Remove) t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() } } discardOneByOne([]statusFile{ {status: "UA", label: "added-them-changed-us.txt"}, {status: "AA", label: "both-added.txt"}, {status: "DD", label: "both-deleted.txt"}, {status: "UU", label: "both-modded.txt"}, {status: "AU", label: "changed-them-added-us.txt"}, {status: "UD", label: "deleted-them.txt"}, {status: "DU", label: "deleted-us.txt"}, }) t.ExpectPopup().Confirmation(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the merge?")). Cancel() discardOneByOne([]statusFile{ {status: "AM", label: "added-changed.txt"}, {status: "MD", label: "change-delete.txt"}, {status: "D ", label: "delete-change.txt"}, {status: "D ", label: "deleted-staged.txt"}, {status: " D", label: "deleted.txt"}, {status: "MM", label: "double-modded.txt"}, {status: "M ", label: "modded-staged.txt"}, {status: " M", label: "modded.txt"}, {status: "A ", label: "new-staged.txt"}, {status: "??", label: "new.txt"}, // the menu title only includes the new file {status: "R ", label: "renamed.txt → renamed2.txt"}, }) t.Views().Files().IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/discard_various_changes_range_select.go000066400000000000000000000041301500612110400310260ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardVariousChangesRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discarding all possible permutations of changed files via range select", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { createAllPossiblePermutationsOfChangedFiles(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" UA added-them-changed-us.txt"), Equals(" AA both-added.txt"), Equals(" DD both-deleted.txt"), Equals(" UU both-modded.txt"), Equals(" AU changed-them-added-us.txt"), Equals(" UD deleted-them.txt"), Equals(" DU deleted-us.txt"), ). SelectNextItem(). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("deleted-us.txt")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() t.ExpectPopup().Confirmation(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the merge?")). Cancel() }). Lines( Equals("▼ /").IsSelected(), Equals(" AM added-changed.txt"), Equals(" MD change-delete.txt"), Equals(" D delete-change.txt"), Equals(" D deleted-staged.txt"), Equals(" D deleted.txt"), Equals(" MM double-modded.txt"), Equals(" M modded-staged.txt"), Equals(" M modded.txt"), Equals(" A new-staged.txt"), Equals(" ?? new.txt"), Equals(" R renamed.txt → renamed2.txt"), ). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("renamed.txt")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() }) t.Views().Files().IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/gitignore.go000066400000000000000000000044101500612110400234320ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Gitignore = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that we can't ignore the .gitignore file, then ignore/exclude other files", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFile(".gitignore", "") shell.CreateFile("toExclude", "") shell.CreateFile("toIgnore", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ?? .gitignore"), Equals(" ?? toExclude"), Equals(" ?? toIgnore"), ). SelectNextItem(). Press(keys.Files.IgnoreFile). // ensure we can't exclude the .gitignore file Tap(func() { t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")).Select(Contains("Add to .git/info/exclude")).Confirm() t.ExpectPopup().Alert().Title(Equals("Error")).Content(Equals("Cannot exclude .gitignore")).Confirm() }). Press(keys.Files.IgnoreFile). // ensure we can't ignore the .gitignore file Tap(func() { t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")).Select(Contains("Add to .gitignore")).Confirm() t.ExpectPopup().Alert().Title(Equals("Error")).Content(Equals("Cannot ignore .gitignore")).Confirm() t.FileSystem().FileContent(".gitignore", Equals("")) t.FileSystem().FileContent(".git/info/exclude", DoesNotContain(".gitignore")) }). SelectNextItem(). Press(keys.Files.IgnoreFile). // exclude a file Tap(func() { t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")).Select(Contains("Add to .git/info/exclude")).Confirm() t.FileSystem().FileContent(".gitignore", Equals("")) t.FileSystem().FileContent(".git/info/exclude", Contains("toExclude")) }). SelectNextItem(). Press(keys.Files.IgnoreFile). // ignore a file Tap(func() { t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")).Select(Contains("Add to .gitignore")).Confirm() t.FileSystem().FileContent(".gitignore", Equals("toIgnore\n")) t.FileSystem().FileContent(".git/info/exclude", Contains("toExclude")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/gitignore_special_characters.go000066400000000000000000000032541500612110400273360ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var GitignoreSpecialCharacters = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Ignore files with special characters in their names", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFile(".gitignore", "") shell.CreateFile("#file", "") shell.CreateFile("file#abc", "") shell.CreateFile("!file", "") shell.CreateFile("file!abc", "") shell.CreateFile("abc*def", "") shell.CreateFile("abc_def", "") shell.CreateFile("file[x]", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { excludeFile := func(fileName string) { t.Views().Files(). NavigateToLine(Contains(fileName)). Press(keys.Files.IgnoreFile) t.ExpectPopup().Menu(). Title(Equals("Ignore or exclude file")). Select(Contains("Add to .gitignore")). Confirm() } t.Views().Files(). Focus(). Lines( Equals("▼ /"), Equals(" ?? !file"), Equals(" ?? #file"), Equals(" ?? .gitignore"), Equals(" ?? abc*def"), Equals(" ?? abc_def"), Equals(" ?? file!abc"), Equals(" ?? file#abc"), Equals(" ?? file[x]"), ) excludeFile("#file") excludeFile("file#abc") excludeFile("!file") excludeFile("file!abc") excludeFile("abc*def") excludeFile("file[x]") t.Views().Files(). Lines( Equals("▼ /"), Equals(" ?? .gitignore"), Equals(" ?? abc_def"), ) t.FileSystem().FileContent(".gitignore", Equals("\\#file\nfile#abc\n\\!file\nfile!abc\nabc\\*def\nfile\\[x\\]\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/remember_commit_message_after_fail.go000066400000000000000000000033451500612110400304770ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var preCommitHook = `#!/bin/bash if [[ -f bad ]]; then exit 1 fi ` var RememberCommitMessageAfterFail = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that the commit message is remembered after a failed attempt at committing", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFile(".git/hooks/pre-commit", preCommitHook) shell.MakeExecutable(".git/hooks/pre-commit") shell.CreateFileAndAdd("one", "one") // the presence of this file will cause the pre-commit hook to fail shell.CreateFile("bad", "bad") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /"), Contains("bad"), Contains("one"), ). Press(keys.Files.CommitChanges). Tap(func() { t.ExpectPopup().CommitMessagePanel().Type("my message").Confirm() t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("Git command failed")).Confirm() }). NavigateToLine(Contains("bad")). Press(keys.Universal.Remove). // remove file that triggers pre-commit hook to fail Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() }). Lines( Contains("one"), ). Press(keys.Files.CommitChanges). Tap(func() { t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("my message")). // it remembered the commit message Confirm() t.Views().Commits(). Lines( Contains("my message"), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/rename_similarity_threshold_change.go000066400000000000000000000025601500612110400305450ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RenameSimilarityThresholdChange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Change the rename similarity threshold while in the files panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("original", "one\ntwo\nthree\nfour\nfive\n") shell.Commit("add original") shell.DeleteFileAndAdd("original") shell.CreateFileAndAdd("renamed", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /"), Equals(" D original"), Equals(" A renamed"), ). Press(keys.Universal.DecreaseRenameSimilarityThreshold). Tap(func() { t.ExpectToast(Equals("Changed rename similarity threshold to 45%")) }). Lines( Equals("R original → renamed"), ). Press(keys.Universal.FocusMainView). Tap(func() { t.Views().Main(). Press(keys.Universal.IncreaseRenameSimilarityThreshold) t.ExpectToast(Equals("Changed rename similarity threshold to 50%")) }). Lines( Equals("▼ /"), Equals(" D original"), Equals(" A renamed"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/renamed_files.go000066400000000000000000000022461500612110400242450ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Regression test for the display of renamed files in the file tree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateDir("dir/nested") shell.CreateFileAndAdd("file1", "file1 content\n") shell.CreateFileAndAdd("dir/file2", "file2 content\n") shell.CreateFileAndAdd("dir/nested/file3", "file3 content\n") shell.Commit("initial commit") shell.RunCommand([]string{"git", "mv", "file1", "dir/file1"}) shell.RunCommand([]string{"git", "mv", "dir/file2", "dir/file2-renamed"}) shell.RunCommand([]string{"git", "mv", "dir/nested/file3", "file3"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /"), Equals(" ▼ dir"), Equals(" R file1 → file1"), Equals(" R file2 → file2-renamed"), Equals(" R dir/nested/file3 → file3"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/shared.go000066400000000000000000000075751500612110400227300ustar00rootroot00000000000000package file import ( . "github.com/jesseduffield/lazygit/pkg/integration/components" ) func createAllPossiblePermutationsOfChangedFiles(shell *Shell) { // typically we would use more bespoke shell methods here, but I struggled to find a way to do that, // and this is copied over from a legacy integration test which did everything in a big shell script // so I'm just copying it across. // common stuff shell.RunShellCommand(`echo test > both-deleted.txt`) shell.RunShellCommand(`git checkout -b conflict && git add both-deleted.txt`) shell.RunShellCommand(`echo bothmodded > both-modded.txt && git add both-modded.txt`) shell.RunShellCommand(`echo haha > deleted-them.txt && git add deleted-them.txt`) shell.RunShellCommand(`echo haha2 > deleted-us.txt && git add deleted-us.txt`) shell.RunShellCommand(`echo mod > modded.txt && git add modded.txt`) shell.RunShellCommand(`echo mod > modded-staged.txt && git add modded-staged.txt`) shell.RunShellCommand(`echo del > deleted.txt && git add deleted.txt`) shell.RunShellCommand(`echo del > deleted-staged.txt && git add deleted-staged.txt`) shell.RunShellCommand(`echo change-delete > change-delete.txt && git add change-delete.txt`) shell.RunShellCommand(`echo delete-change > delete-change.txt && git add delete-change.txt`) shell.RunShellCommand(`echo double-modded > double-modded.txt && git add double-modded.txt`) shell.RunShellCommand(`echo "renamed\nhaha" > renamed.txt && git add renamed.txt`) shell.RunShellCommand(`git commit -m one`) // stuff on other branch shell.RunShellCommand(`git branch conflict_second && git mv both-deleted.txt added-them-changed-us.txt`) shell.RunShellCommand(`git commit -m "both-deleted.txt renamed in added-them-changed-us.txt"`) shell.RunShellCommand(`echo blah > both-added.txt && git add both-added.txt`) shell.RunShellCommand(`echo mod1 > both-modded.txt && git add both-modded.txt`) shell.RunShellCommand(`rm deleted-them.txt && git add deleted-them.txt`) shell.RunShellCommand(`echo modded > deleted-us.txt && git add deleted-us.txt`) shell.RunShellCommand(`git commit -m "two"`) // stuff on our branch shell.RunShellCommand(`git checkout conflict_second`) shell.RunShellCommand(`git mv both-deleted.txt changed-them-added-us.txt`) shell.RunShellCommand(`git commit -m "both-deleted.txt renamed in changed-them-added-us.txt"`) shell.RunShellCommand(`echo mod2 > both-modded.txt && git add both-modded.txt`) shell.RunShellCommand(`echo blah2 > both-added.txt && git add both-added.txt`) shell.RunShellCommand(`echo modded > deleted-them.txt && git add deleted-them.txt`) shell.RunShellCommand(`rm deleted-us.txt && git add deleted-us.txt`) shell.RunShellCommand(`git commit -m "three"`) shell.RunShellCommand(`git reset --hard conflict_second`) shell.RunCommandExpectError([]string{"git", "merge", "conflict"}) shell.RunShellCommand(`echo "new" > new.txt`) shell.RunShellCommand(`echo "new staged" > new-staged.txt && git add new-staged.txt`) shell.RunShellCommand(`echo mod2 > modded.txt`) shell.RunShellCommand(`echo mod2 > modded-staged.txt && git add modded-staged.txt`) shell.RunShellCommand(`rm deleted.txt`) shell.RunShellCommand(`rm deleted-staged.txt && git add deleted-staged.txt`) shell.RunShellCommand(`echo change-delete2 > change-delete.txt && git add change-delete.txt`) shell.RunShellCommand(`rm change-delete.txt`) shell.RunShellCommand(`rm delete-change.txt && git add delete-change.txt`) shell.RunShellCommand(`echo "changed" > delete-change.txt`) shell.RunShellCommand(`echo "change1" > double-modded.txt && git add double-modded.txt`) shell.RunShellCommand(`echo "change2" > double-modded.txt`) shell.RunShellCommand(`echo before > added-changed.txt && git add added-changed.txt`) shell.RunShellCommand(`echo after > added-changed.txt`) shell.RunShellCommand(`rm renamed.txt && git add renamed.txt`) shell.RunShellCommand(`echo "renamed\nhaha" > renamed2.txt && git add renamed2.txt`) } lazygit-0.50.0+ds1/pkg/integration/tests/file/stage_children_range_select.go000066400000000000000000000024371500612110400271400ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StageChildrenRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stage a range of files/folders and their children using range select", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFile("foo", "") shell.CreateFile("foobar", "") shell.CreateFile("baz/file", "") shell.CreateFile("bazbam/file", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ baz"), Equals(" ?? file"), Equals(" ▼ bazbam"), Equals(" ?? file"), Equals(" ?? foo"), Equals(" ?? foobar"), ). // Select everything Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("foobar")). // Stage PressPrimaryAction(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ baz").IsSelected(), Equals(" A file").IsSelected(), Equals(" ▼ bazbam").IsSelected(), Equals(" A file").IsSelected(), Equals(" A foo").IsSelected(), Equals(" A foobar").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/stage_deleted_range_select.go000066400000000000000000000026531500612110400267560ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StageDeletedRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stage a range of deleted files using range select", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file-a", "") shell.CreateFileAndAdd("file-b", "") shell.Commit("first commit") shell.DeleteFile("file-a") shell.DeleteFile("file-b") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" D file-a"), Equals(" D file-b"), ). SelectNextItem(). // Stage a single deleted file PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" D file-a").IsSelected(), Equals(" D file-b"), ). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file-b")). // Stage both files while a deleted file is already staged PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" D file-a").IsSelected(), Equals(" D file-b").IsSelected(), ). // Unstage; back to everything being unstaged PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" D file-a").IsSelected(), Equals(" D file-b").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/file/stage_range_select.go000066400000000000000000000061621500612110400252670ustar00rootroot00000000000000package file import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StageRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stage/unstage a range of files using range select", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("dir2/file-d", "old content") shell.Commit("first commit") shell.UpdateFile("dir2/file-d", "new content") shell.CreateFile("dir1/file-a", "") shell.CreateFile("dir1/file-b", "") shell.CreateFile("dir2/file-c", "") shell.CreateFile("file-e", "") shell.CreateFile("file-f", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" ?? file-b"), Equals(" ▼ dir2"), Equals(" ?? file-c"), Equals(" M file-d"), Equals(" ?? file-e"), Equals(" ?? file-f"), ). NavigateToLine(Contains("file-b")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file-c")). // Stage PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" A file-b").IsSelected(), Equals(" ▼ dir2").IsSelected(), Equals(" A file-c").IsSelected(), // Staged because dir2 was part of the selection when he hit space Equals(" M file-d"), Equals(" ?? file-e"), Equals(" ?? file-f"), ). // Unstage; back to everything being unstaged PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" ?? file-b").IsSelected(), Equals(" ▼ dir2").IsSelected(), Equals(" ?? file-c").IsSelected(), Equals(" M file-d"), Equals(" ?? file-e"), Equals(" ?? file-f"), ). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("dir2")). // Verify that collapsed directories can be included in the range. // Collapse the directory PressEnter(). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" ?? file-b"), Equals(" ▶ dir2").IsSelected(), Equals(" ?? file-e"), Equals(" ?? file-f"), ). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file-e")). // Stage PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" ?? file-b"), Equals(" ▶ dir2").IsSelected(), Equals(" A file-e").IsSelected(), Equals(" ?? file-f"), ). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("dir2")). // Expand the directory again to verify it's been staged PressEnter(). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ?? file-a"), Equals(" ?? file-b"), Equals(" ▼ dir2").IsSelected(), Equals(" A file-c"), Equals(" M file-d"), Equals(" A file-e"), Equals(" ?? file-f"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/000077500000000000000000000000001500612110400236325ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_by_file_status.go000066400000000000000000000036111500612110400305430ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterByFileStatus = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filtering to show untracked files in repo that hides them by default", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { // need to set untracked files to not be displayed in git config shell.SetConfig("status.showUntrackedFiles", "no") shell.CreateFileAndAdd("file-tracked", "foo") shell.Commit("first commit") shell.CreateFile("file-untracked", "bar") shell.UpdateFile("file-tracked", "baz") shell.CreateFile("file-staged-but-untracked", "qux") shell.GitAdd("file-staged-but-untracked") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Focus(). Lines( Equals("▼ /").IsSelected(), Equals(" A file-staged-but-untracked"), Equals(" M file-tracked"), ). Press(keys.Files.OpenStatusFilter). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Show only untracked files")). Confirm() }). Lines( Equals("?? file-untracked").IsSelected(), ). Press(keys.Files.OpenStatusFilter). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Show only tracked files")). Confirm() }). Lines( Equals("▼ /").IsSelected(), Equals(" A file-staged-but-untracked"), Equals(" M file-tracked"), ). Press(keys.Files.OpenStatusFilter). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("No filter")). Confirm() }). Lines( Equals("▼ /").IsSelected(), Equals(" A file-staged-but-untracked"), Equals(" M file-tracked"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_commit_files.go000066400000000000000000000046171500612110400302100ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterCommitFiles = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Basic commit file filtering by text", ExtraCmdArgs: []string{}, Skip: true, // skipping until we have implemented file view filtering SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("folder1") shell.CreateFileAndAdd("folder1/apple-grape", "apple-grape") shell.CreateFileAndAdd("folder1/apple-orange", "apple-orange") shell.CreateFileAndAdd("folder1/grape-orange", "grape-orange") shell.Commit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains(`first commit`).IsSelected(), ). Press(keys.Universal.Confirm) t.Views().CommitFiles(). IsFocused(). Lines( Contains(`folder1`).IsSelected(), Contains(`apple-grape`), Contains(`apple-orange`), Contains(`grape-orange`), ). Press(keys.Files.ToggleTreeView). Lines( Contains(`folder1/apple-grape`).IsSelected(), Contains(`folder1/apple-orange`), Contains(`folder1/grape-orange`), ). FilterOrSearch("apple"). Lines( Contains(`folder1/apple-grape`).IsSelected(), Contains(`folder1/apple-orange`), ). Press(keys.Files.ToggleTreeView). // filter still applies when we toggle tree view Lines( Contains(`folder1`), Contains(`apple-grape`).IsSelected(), Contains(`apple-orange`), ). Press(keys.Files.ToggleTreeView). Lines( Contains(`folder1/apple-grape`).IsSelected(), Contains(`folder1/apple-orange`), ). NavigateToLine(Contains(`folder1/apple-orange`)). Press(keys.Universal.Return). Lines( Contains(`folder1/apple-grape`), // selection is retained after escaping filter mode Contains(`folder1/apple-orange`).IsSelected(), Contains(`folder1/grape-orange`), ). Tap(func() { t.Views().Search().IsInvisible() }). Press(keys.Files.ToggleTreeView). Lines( Contains(`folder1`), Contains(`apple-grape`), Contains(`apple-orange`).IsSelected(), Contains(`grape-orange`), ). FilterOrSearch("folder1/grape"). Lines( // first item is always selected after filtering Contains(`folder1`).IsSelected(), Contains(`grape-orange`), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_files.go000066400000000000000000000043071500612110400266340ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterFiles = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Basic file filtering by text", ExtraCmdArgs: []string{}, Skip: true, // Skipping until we have implemented file view filtering SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("folder1") shell.CreateFile("folder1/apple-grape", "apple-grape") shell.CreateFile("folder1/apple-orange", "apple-orange") shell.CreateFile("folder1/grape-orange", "grape-orange") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Focus(). Lines( Contains(`folder1`).IsSelected(), Contains(`apple-grape`), Contains(`apple-orange`), Contains(`grape-orange`), ). Press(keys.Files.ToggleTreeView). Lines( Contains(`folder1/apple-grape`).IsSelected(), Contains(`folder1/apple-orange`), Contains(`folder1/grape-orange`), ). FilterOrSearch("apple"). Lines( Contains(`folder1/apple-grape`).IsSelected(), Contains(`folder1/apple-orange`), ). Press(keys.Files.ToggleTreeView). // filter still applies when we toggle tree view Lines( Contains(`folder1`), Contains(`apple-grape`).IsSelected(), Contains(`apple-orange`), ). Press(keys.Files.ToggleTreeView). Lines( Contains(`folder1/apple-grape`).IsSelected(), Contains(`folder1/apple-orange`), ). NavigateToLine(Contains(`folder1/apple-orange`)). Press(keys.Universal.Return). Lines( Contains(`folder1/apple-grape`), // selection is retained after escaping filter mode Contains(`folder1/apple-orange`).IsSelected(), Contains(`folder1/grape-orange`), ). Tap(func() { t.Views().Search().IsInvisible() }). Press(keys.Files.ToggleTreeView). Lines( Contains(`folder1`), Contains(`apple-grape`), Contains(`apple-orange`).IsSelected(), Contains(`grape-orange`), ). FilterOrSearch("folder1/grape"). Lines( // first item is always selected after filtering Contains(`folder1`).IsSelected(), Contains(`grape-orange`), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_fuzzy.go000066400000000000000000000020311500612110400267110ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterFuzzy = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that fuzzy filtering works (not just exact matches)", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.FilterMode = "fuzzy" }, SetupRepo: func(shell *Shell) { shell.NewBranch("this-is-my-branch") shell.EmptyCommit("first commit") shell.NewBranch("other-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains(`other-branch`).IsSelected(), Contains(`this-is-my-branch`), ). FilterOrSearch("timb"). // using first letters of words Lines( Contains(`this-is-my-branch`).IsSelected(), ). FilterOrSearch("brnch"). // allows missing letter Lines( Contains(`other-branch`).IsSelected(), Contains(`this-is-my-branch`), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_menu.go000066400000000000000000000026011500612110400264710ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterMenu = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filtering the keybindings menu", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile("myfile", "myfile") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains(`??`).Contains(`myfile`).IsSelected(), ). Press(keys.Universal.OptionMenu). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Keybindings")). Filter("Ignore"). Lines( // menu has filtered down to the one item that matches the filter Contains(`--- Local ---`), Contains(`Ignore`).IsSelected(), ). Confirm() t.ExpectPopup().Menu(). Title(Equals("Ignore or exclude file")). Select(Contains("Add to .gitignore")). Confirm() }) t.Views().Files(). IsFocused(). Lines( // file has been ignored Contains(`.gitignore`).IsSelected(), ). // Upon opening the menu again, the filter should have been reset Press(keys.Universal.OptionMenu). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Keybindings")). LineCount(GreaterThan(1)) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go000066400000000000000000000021421500612110400337160ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterMenuCancelFilterWithEscape = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filtering the keybindings menu, then pressing esc to turn off the filter", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsFocused(). Press(keys.Universal.OptionMenu) t.ExpectPopup().Menu(). Title(Equals("Keybindings")). Filter("Ignore"). Lines( // menu has filtered down to the one item that matches the filter Contains(`--- Local ---`), Contains(`Ignore`).IsSelected(), ) // Escape should cancel the filter, not close the menu t.GlobalPress(keys.Universal.Return) t.ExpectPopup().Menu(). Title(Equals("Keybindings")). LineCount(GreaterThan(1)) // Another escape closes the menu t.GlobalPress(keys.Universal.Return) t.Views().Files().IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go000066400000000000000000000017201500612110400326070ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterMenuWithNoKeybindings = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filtering the keybindings menu so that only entries without keybinding are left", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Keybinding.Universal.ToggleWhitespaceInDiffView = "" }, SetupRepo: func(shell *Shell) { }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Press(keys.Universal.OptionMenu) t.ExpectPopup().Menu(). Title(Equals("Keybindings")). Filter("whitespace"). Lines( // menu has filtered down to the one item that matches the // filter, and it doesn't have a keybinding Equals("--- Global ---"), Equals("Toggle whitespace").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_remote_branches.go000066400000000000000000000025141500612110400306700ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterRemoteBranches = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filtering remote branches", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-apple") shell.EmptyCommit("commit-one") shell.NewBranch("branch-grape") shell.NewBranch("branch-orange") shell.CloneIntoRemote("origin") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Remotes(). Focus(). Lines( Contains(`origin`).IsSelected(), ). PressEnter() t.Views().RemoteBranches(). IsFocused(). Lines( Contains(`branch-apple`).IsSelected(), Contains(`branch-grape`), Contains(`branch-orange`), ). FilterOrSearch("grape"). Lines( Contains(`branch-grape`).IsSelected(), ). // cancel the filter PressEscape(). Tap(func() { t.Views().Search().IsInvisible() }). Lines( Contains(`branch-apple`), Contains(`branch-grape`).IsSelected(), Contains(`branch-orange`), ). // return to remotes view PressEscape() t.Views().Remotes(). IsFocused(). Lines( Contains(`origin`).IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_remotes.go000066400000000000000000000017641500612110400272140ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterRemotes = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filtering remotes", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("commit-one") shell.CloneIntoRemote("remote1") shell.CloneIntoRemote("remote2") shell.CloneIntoRemote("remote3") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Remotes(). Focus(). Lines( Contains("remote1").IsSelected(), Contains("remote2"), Contains("remote3"), ). FilterOrSearch("2"). Lines( Contains("remote2").IsSelected(), ). // cancel the filter PressEscape(). Tap(func() { t.Views().Search().IsInvisible() }). Lines( Contains("remote1"), Contains("remote2").IsSelected(), Contains("remote3"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_search_history.go000066400000000000000000000041561500612110400305620ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterSearchHistory = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Navigating search history", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). // populate search history with some values FilterOrSearch("1"). FilterOrSearch("2"). FilterOrSearch("3"). Press(keys.Universal.StartSearch). // clear initial search value Tap(func() { t.ExpectSearch().Clear() }). // test main search history functionality Tap(func() { t.Views().Search(). Press(keys.Universal.PrevItem). Content(Contains("3")). Press(keys.Universal.PrevItem). Content(Contains("2")). Press(keys.Universal.PrevItem). Content(Contains("1")). Press(keys.Universal.PrevItem). Content(Contains("1")). Press(keys.Universal.NextItem). Content(Contains("2")). Press(keys.Universal.NextItem). Content(Contains("3")). Press(keys.Universal.NextItem). Content(Contains("")). Press(keys.Universal.NextItem). Content(Contains("")). Press(keys.Universal.PrevItem). Content(Contains("3")). PressEscape() }). // test that it resets after you enter and exit a search Press(keys.Universal.StartSearch). Tap(func() { t.Views().Search(). Press(keys.Universal.PrevItem). Content(Contains("3")). PressEscape() }) // test that the histories are separate for each view t.Views().Commits(). Focus(). FilterOrSearch("a"). FilterOrSearch("b"). FilterOrSearch("c"). Press(keys.Universal.StartSearch). Tap(func() { t.ExpectSearch().Clear() }). Tap(func() { t.Views().Search(). Press(keys.Universal.PrevItem). Content(Contains("c")). Press(keys.Universal.PrevItem). Content(Contains("b")). Press(keys.Universal.PrevItem). Content(Contains("a")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/filter_updates_when_model_changes.go000066400000000000000000000037031500612110400330670ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FilterUpdatesWhenModelChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that after deleting a branch the filter is reapplied to show only the remaining branches", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.NewBranch("branch-to-delete") shell.NewBranch("other") shell.NewBranch("checked-out-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("checked-out-branch").IsSelected(), Contains("other"), Contains("branch-to-delete"), Contains("master"), ). FilterOrSearch("branch"). Lines( Contains("checked-out-branch").IsSelected(), Contains("branch-to-delete"), ). SelectNextItem(). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'branch-to-delete'?")). Select(Contains("Delete local branch")). Confirm() }). Lines( Contains("checked-out-branch").IsSelected(), ) // Verify that updating the filter works even if the view is not the active one t.Views().Files().Focus() // To do that, we use a custom command to create a new branch that matches the filter t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). Type("git branch new-branch"). Confirm() t.Views().Branches(). Lines( Contains("checked-out-branch").IsSelected(), Contains("new-branch"), ) t.Views().Branches(). Focus(). // cancel the filter PressEscape(). Lines( Contains("checked-out-branch").IsSelected(), Contains("other"), Contains("master"), Contains("new-branch"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/nested_filter.go000066400000000000000000000074501500612110400270160ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var NestedFilter = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filter in the several nested panels and verify the filters are preserved as you escape back to the surface", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // need to create some branches, each with their own commits shell.NewBranch("branch-gold") shell.CreateFileAndAdd("apple", "apple") shell.CreateFileAndAdd("orange", "orange") shell.CreateFileAndAdd("grape", "grape") shell.Commit("commit-knife") shell.NewBranch("branch-silver") shell.UpdateFileAndAdd("apple", "apple-2") shell.UpdateFileAndAdd("orange", "orange-2") shell.UpdateFileAndAdd("grape", "grape-2") shell.Commit("commit-spoon") shell.NewBranch("branch-bronze") shell.UpdateFileAndAdd("apple", "apple-3") shell.UpdateFileAndAdd("orange", "orange-3") shell.UpdateFileAndAdd("grape", "grape-3") shell.Commit("commit-fork") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains(`branch-bronze`).IsSelected(), Contains(`branch-silver`), Contains(`branch-gold`), ). FilterOrSearch("sil"). Lines( Contains(`branch-silver`).IsSelected(), ). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains(`commit-spoon`).IsSelected(), Contains(`commit-knife`), ). FilterOrSearch("knife"). Lines( // sub-commits view searches, it doesn't filter, so we haven't filtered down the list Contains(`commit-spoon`), Contains(`commit-knife`).IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" A apple"), Equals(" A grape"), Equals(" A orange"), ). FilterOrSearch("grape"). Lines( Equals("▼ /"), Equals(" A apple"), Equals(" A grape").IsSelected(), Equals(" A orange"), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). FilterOrSearch("newline"). SelectedLine(Contains("No newline at end of file")). PressEscape(). // cancel search Tap(func() { t.Views().Search().IsInvisible() }). // escape to commit-files view PressEscape() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /"), Equals(" A apple"), Equals(" A grape").IsSelected(), Equals(" A orange"), ). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'grape'")) }). // cancel search PressEscape(). Tap(func() { t.Views().Search().IsInvisible() }). Lines( Equals("▼ /"), Equals(" A apple"), Equals(" A grape").IsSelected(), Equals(" A orange"), ). // escape to sub-commits view PressEscape() t.Views().SubCommits(). IsFocused(). Lines( Contains(`commit-spoon`), Contains(`commit-knife`).IsSelected(), ). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'knife'")) }). // cancel search PressEscape(). Tap(func() { t.Views().Search().IsInvisible() }). Lines( Contains(`commit-spoon`), // still selected Contains(`commit-knife`).IsSelected(), ). // escape to branches view PressEscape() t.Views().Branches(). IsFocused(). Lines( Contains(`branch-silver`).IsSelected(), ). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'sil'")) }). // cancel search PressEscape(). Tap(func() { t.Views().Search().IsInvisible() }). Lines( Contains(`branch-bronze`), Contains(`branch-silver`).IsSelected(), Contains(`branch-gold`), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/nested_filter_transient.go000066400000000000000000000057771500612110400311170ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // This one requires some explanation: the sub-commits and diff-file contexts are // 'transient' in that they are spawned inside a window when you need them, but // can be relocated elsewhere if you need them somewhere else. So for example if // I hit enter on a branch I'll see the sub-commits view, but if I then navigate // to the reflog context and hit enter on a reflog, the sub-commits view is moved // to the reflog window. This is because we reuse the same view (it's a limitation // that would be nice to remove in the future). // Nonetheless, we need to ensure that upon moving the view, the filter is cancelled. var NestedFilterTransient = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filter in a transient panel (sub-commits and diff-files) and ensure filter is cancelled when the panel is moved", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // need to create some branches, each with their own commits shell.NewBranch("mybranch") shell.CreateFileAndAdd("file-one", "file-one") shell.CreateFileAndAdd("file-two", "file-two") shell.Commit("commit-one") shell.EmptyCommit("commit-two") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains(`mybranch`).IsSelected(), ). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains(`commit-two`).IsSelected(), Contains(`commit-one`), ). FilterOrSearch("one"). Lines( Contains(`commit-two`), Contains(`commit-one`).IsSelected(), ) t.Views().ReflogCommits(). Focus(). SelectedLine(Contains("commit: commit-two")). PressEnter() t.Views().SubCommits(). IsFocused(). // the search on the sub-commits context has been cancelled Lines( Contains(`commit-two`).IsSelected(), Contains(`commit-one`), ). Tap(func() { t.Views().Search().IsInvisible() }). NavigateToLine(Contains("commit-one")). PressEnter() // Now let's test the commit files context t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" A file-one"), Equals(" A file-two"), ). FilterOrSearch("two"). Lines( Equals("▼ /"), Equals(" A file-one"), Equals(" A file-two").IsSelected(), ) t.Views().Branches(). Focus(). SelectedLine(Contains("mybranch")). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains(`commit-two`).IsSelected(), Contains(`commit-one`), ). NavigateToLine(Contains("commit-one")). PressEnter() t.Views().CommitFiles(). IsFocused(). // the search on the commit-files context has been cancelled Lines( Equals("▼ /").IsSelected(), Equals(" A file-one"), Equals(" A file-two"), ). Tap(func() { t.Views().Search().IsInvisible() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_search/new_search.go000066400000000000000000000023101500612110400262730ustar00rootroot00000000000000package filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // This is a regression test to ensure https://github.com/jesseduffield/lazygit/issues/2971 // doesn't happen again var NewSearch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Start a new search and verify the search begins from the current cursor position, not from the current search match", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // need to create some branches, each with their own commits shell.EmptyCommit("Add foo") shell.EmptyCommit("Remove foo") shell.EmptyCommit("Add bar") shell.EmptyCommit("Remove bar") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains(`Remove bar`).IsSelected(), Contains(`Add bar`), Contains(`Remove foo`), Contains(`Add foo`), ). FilterOrSearch("Add"). SelectedLine(Contains(`Add bar`)). SelectPreviousItem(). SelectedLine(Contains(`Remove bar`)). FilterOrSearch("Remove"). SelectedLine(Contains(`Remove bar`)) }, }) staging_folder_stages_only_tracked_files_in_tracked_only_filter.go000066400000000000000000000030261500612110400411510ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/filter_and_searchpackage filter_and_search import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StagingFolderStagesOnlyTrackedFilesInTrackedOnlyFilter = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Staging entire folder in tracked only view, should stage only tracked files", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.CreateDir("test") shell.CreateFileAndAdd("test/file-tracked", "foo") shell.Commit("first commit") shell.CreateFile("test/file-untracked", "bar") shell.UpdateFile("test/file-tracked", "baz") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Focus(). Lines( Equals("▼ test").IsSelected(), Equals(" M file-tracked"), Equals(" ?? file-untracked"), ). Press(keys.Files.OpenStatusFilter). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Show only tracked files")). Confirm() }). Lines( Equals("▼ test").IsSelected(), Equals(" M file-tracked"), ). PressPrimaryAction(). Press(keys.Files.OpenStatusFilter). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("No filter")). Confirm() }). Lines( Equals("▼ test").IsSelected(), Equals(" M file-tracked"), // 'M' is now in the left column, so file is staged Equals(" ?? file-untracked"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_author/000077500000000000000000000000001500612110400235375ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_author/select_author.go000066400000000000000000000035011500612110400267260ustar00rootroot00000000000000package filter_by_author import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SelectAuthor = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filter commits using the currently highlighted commit's author when the commit view is active", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { commonSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). SelectedLineIdx(0). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Filter by 'Paul Oberstein '")). Confirm() t.Views().Commits(). IsFocused(). Lines( Contains("commit 7"), Contains("commit 6"), Contains("commit 5"), Contains("commit 4"), Contains("commit 3"), Contains("commit 2"), Contains("commit 1"), Contains("commit 0"), ) t.Views().Information().Content(Contains("Filtering by 'Paul Oberstein '")) t.Views().Commits(). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Stop filtering")). Confirm() t.Views().Commits(). IsFocused(). NavigateToLine(Contains("SK commit 0")). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Filter by 'Siegfried Kircheis '")). Confirm() t.Views().Commits(). IsFocused(). Lines( Contains("commit 0"), ) t.Views().Information().Content(Contains("Filtering by 'Siegfried Kircheis '")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_author/shared.go000066400000000000000000000013611500612110400253350ustar00rootroot00000000000000package filter_by_author import ( "fmt" "strings" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) type AuthorInfo struct { name string numberOfCommits int } func commonSetup(shell *Shell) { authors := []AuthorInfo{{"Yang Wen-li", 3}, {"Siegfried Kircheis", 1}, {"Paul Oberstein", 8}} totalCommits := 0 repoStartDaysAgo := 100 for _, authorInfo := range authors { for i := 0; i < authorInfo.numberOfCommits; i++ { authorEmail := strings.ToLower(strings.ReplaceAll(authorInfo.name, " ", ".")) + "@email.com" commitMessage := fmt.Sprintf("commit %d", i) shell.SetAuthor(authorInfo.name, authorEmail) shell.EmptyCommitDaysAgo(commitMessage, repoStartDaysAgo-totalCommits) totalCommits++ } } } lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_author/type_author.go000066400000000000000000000032301500612110400264270ustar00rootroot00000000000000package filter_by_author import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var TypeAuthor = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filter commits by author using the typed in author", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { commonSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status(). Focus(). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Enter author to filter by")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("Enter author:")). Type("Yang"). SuggestionLines(Equals("Yang Wen-li ")). ConfirmFirstSuggestion() t.Views().Commits(). IsFocused(). Lines( Contains("commit 2"), Contains("commit 1"), Contains("commit 0"), ) t.Views().Information().Content(Contains("Filtering by 'Yang Wen-li '")) t.Views().Status(). Focus(). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Enter author to filter by")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("Enter author:")). Type("Siegfried"). SuggestionLines(Equals("Siegfried Kircheis ")). ConfirmFirstSuggestion() t.Views().Commits(). IsFocused(). Lines( Contains("commit 0"), ) t.Views().Information().Content(Contains("Filtering by 'Siegfried Kircheis '")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_path/000077500000000000000000000000001500612110400231715ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_path/cli_arg.go000066400000000000000000000010131500612110400251130ustar00rootroot00000000000000package filter_by_path import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CliArg = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filter commits by file path, using CLI arg", ExtraCmdArgs: []string{"-f=filterFile"}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { commonSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { postFilterTest(t) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_path/keep_same_commit_selected_on_exit.go000066400000000000000000000025161500612110400324220ustar00rootroot00000000000000package filter_by_path import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var KeepSameCommitSelectedOnExit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "When exiting filtering mode, keep the same commit selected if possible", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { commonSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains(`none of the two`).IsSelected(), Contains(`only filterFile`), Contains(`only otherFile`), Contains(`both files`), ).Press(keys.Universal.FilteringMenu). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Enter path to filter by")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("Enter path:")). Type("filterF"). SuggestionLines(Equals("filterFile")). ConfirmFirstSuggestion() }). Lines( Contains(`only filterFile`).IsSelected(), Contains(`both files`), ). SelectNextItem(). PressEscape(). Lines( Contains(`none of the two`), Contains(`only filterFile`), Contains(`only otherFile`), Contains(`both files`).IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_path/select_file.go000066400000000000000000000021401500612110400257730ustar00rootroot00000000000000package filter_by_path import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SelectFile = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filter commits by file path, by finding file in UI and filtering on it", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { commonSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains(`none of the two`).IsSelected(), Contains(`only filterFile`), Contains(`only otherFile`), Contains(`both files`), ). NavigateToLine(Contains(`only filterFile`)). PressEnter() // when you click into the commit itself, you see all files from that commit t.Views().CommitFiles(). IsFocused(). Lines( Contains(`filterFile`).IsSelected(), ). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu().Title(Equals("Filtering")).Select(Contains("Filter by 'filterFile'")).Confirm() postFilterTest(t) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_path/shared.go000066400000000000000000000022271500612110400247710ustar00rootroot00000000000000package filter_by_path import ( . "github.com/jesseduffield/lazygit/pkg/integration/components" ) func commonSetup(shell *Shell) { shell.CreateFileAndAdd("filterFile", "original filterFile content") shell.CreateFileAndAdd("otherFile", "original otherFile content") shell.Commit("both files") shell.UpdateFileAndAdd("otherFile", "new otherFile content") shell.Commit("only otherFile") shell.UpdateFileAndAdd("filterFile", "new filterFile content") shell.Commit("only filterFile") shell.EmptyCommit("none of the two") } func postFilterTest(t *TestDriver) { t.Views().Information().Content(Contains("Filtering by 'filterFile'")) t.Views().Commits(). IsFocused(). Lines( Contains(`only filterFile`).IsSelected(), Contains(`both files`), ). SelectNextItem() // we only show the filtered file's changes in the main view t.Views().Main(). Content(Contains("filterFile").DoesNotContain("otherFile")) t.Views().Commits(). PressEnter() // when you click into the commit itself, you see all files from that commit t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /"), Contains(`filterFile`), Contains(`otherFile`), ) } lazygit-0.50.0+ds1/pkg/integration/tests/filter_by_path/type_file.go000066400000000000000000000015661500612110400255100ustar00rootroot00000000000000package filter_by_path import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var TypeFile = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filter commits by file path, by finding file in UI and filtering on it", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { commonSetup(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu(). Title(Equals("Filtering")). Select(Contains("Enter path to filter by")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("Enter path:")). Type("filterF"). SuggestionLines(Equals("filterFile")). ConfirmFirstSuggestion() postFilterTest(t) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/000077500000000000000000000000001500612110400240345ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go000066400000000000000000000034201500612110400320450ustar00rootroot00000000000000package interactive_rebase import ( "fmt" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) const ( BASE_BRANCH = "base-branch" TOP_BRANCH = "top-branch" BASE_COMMIT = "base-commit" TOP_COMMIT = "top-commit" ) var AdvancedInteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "It begins an interactive rebase and verifies to have the possibility of editing the commits of the branch before proceeding with the actual rebase", ExtraCmdArgs: []string{}, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. NewBranch(BASE_BRANCH). EmptyCommit(BASE_COMMIT). NewBranch(TOP_BRANCH). EmptyCommit(TOP_COMMIT) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains(TOP_COMMIT), Contains(BASE_COMMIT), ) t.Views().Branches(). Focus(). NavigateToLine(Contains(BASE_BRANCH)). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals(fmt.Sprintf("Rebase '%s'", TOP_BRANCH))). Select(Contains("Interactive rebase")). Confirm() t.Views().Commits(). IsFocused(). Lines( Contains("--- Pending rebase todos ---"), Contains(TOP_COMMIT), Contains("--- Commits ---"), Contains(BASE_COMMIT), ). NavigateToLine(Contains(TOP_COMMIT)). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains(TOP_COMMIT).Contains("edit"), Contains("--- Commits ---"), Contains(BASE_COMMIT), ). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("--- Pending rebase todos ---"), Contains("--- Commits ---"), Contains(TOP_COMMIT), Contains(BASE_COMMIT), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go000066400000000000000000000037661500612110400317470ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends a staged file to a commit, causing a conflict there.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "1\n").Commit("one") shell.UpdateFileAndAdd("file", "1\n2\n").Commit("two") shell.UpdateFileAndAdd("file", "1\n2\n3\n").Commit("three") shell.UpdateFileAndAdd("file", "1\n2\n4\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("three"), Contains("two"), Contains("one"), ). NavigateToLine(Contains("two")). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Amend commit")). Content(Contains("Are you sure you want to amend this commit with your staged files?")). Confirm() t.Common().AcknowledgeConflicts() }). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("three"), Contains("fixup").Contains("<-- CONFLICT --- fixup! two"), Contains("--- Commits ---"), Contains("two"), Contains("one"), ) t.Views().Files(). IsFocused(). Lines( Contains("UU file"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). TopLines( Contains("1"), Contains("2"), Contains("<<<<<<< HEAD"), Contains("======="), Contains("4"), Contains(">>>>>>>"), ). SelectNextItem(). PressPrimaryAction() // pick "4" t.Common().ContinueOnConflictsResolved("rebase") t.Common().AcknowledgeConflicts() t.Views().Commits(). Lines( Contains("--- Pending rebase todos ---"), Contains("<-- CONFLICT --- three"), Contains("--- Commits ---"), Contains("two"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/amend_first_commit.go000066400000000000000000000021151500612110400302250ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends a staged file to the first (initial) commit.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(2). CreateFileAndAdd("fixup-file", "fixup content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Amend commit")). Content(Contains("Are you sure you want to amend this commit with your staged files?")). Confirm() }). Lines( Contains("commit 02"), Contains("commit 01").IsSelected(), ) t.Views().Main(). Content(Contains("fixup content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go000066400000000000000000000030001500612110400302230ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendFixupCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends a staged file to a fixup commit, and checks that other unrelated fixup commits are not auto-squashed.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). CreateFileAndAdd("first-fixup-file", "").Commit("fixup! commit 01"). CreateNCommitsStartingAt(2, 2). CreateFileAndAdd("unrelated-fixup-file", "fixup 03").Commit("fixup! commit 03"). CreateFileAndAdd("fixup-file", "fixup 01") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("fixup! commit 03"), Contains("commit 03"), Contains("commit 02"), Contains("fixup! commit 01"), Contains("commit 01"), ). NavigateToLine(Contains("fixup! commit 01")). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Amend commit")). Content(Contains("Are you sure you want to amend this commit with your staged files?")). Confirm() }). Lines( Contains("fixup! commit 03"), Contains("commit 03"), Contains("commit 02"), Contains("fixup! commit 01").IsSelected(), Contains("commit 01"), ) t.Views().Main(). Content(Contains("fixup 01")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go000066400000000000000000000032221500612110400326700ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends the current head commit from the commits panel during a rebase.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateNCommits(3) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 02").IsSelected(), Contains("commit 01"), ) t.Shell().CreateFile("fixup-file", "fixup content") t.Views().Files(). Focus(). Press(keys.Files.RefreshFiles). Lines( Contains("??").Contains("fixup-file").IsSelected(), ). PressPrimaryAction() t.Views().Commits(). Focus(). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Amend commit")). Content(Contains("Are you sure you want to amend this commit with your staged files?")). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 02").IsSelected(), Contains("commit 01"), ) t.Views().Main(). Content(Contains("fixup content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/amend_merge.go000066400000000000000000000034421500612110400266310ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ( postMergeFileContent = "post merge file content" postMergeFilename = "post-merge-file" ) var AmendMerge = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Amends a staged file to a merge commit.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. NewBranch("development-branch"). CreateFileAndAdd("initial-file", "initial file content"). Commit("initial commit"). NewBranch("feature-branch"). // it's also checked out automatically CreateFileAndAdd("new-feature-file", "new content"). Commit("new feature commit"). Checkout("development-branch"). Merge("feature-branch"). CreateFileAndAdd(postMergeFilename, postMergeFileContent) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { mergeCommitMessage := "Merge branch 'feature-branch' into development-branch" t.Views().Commits(). Lines( Contains(mergeCommitMessage), Contains("new feature commit"), Contains("initial commit"), ) t.Views().Commits(). Focus(). Press(keys.Commits.AmendToCommit) t.ExpectPopup().Confirmation(). Title(Equals("Amend commit")). Content(Contains("Are you sure you want to amend this commit with your staged files?")). Confirm() // assuring we haven't added a brand new commit t.Views().Commits(). Lines( Contains(mergeCommitMessage), Contains("new feature commit"), Contains("initial commit"), ) // assuring the post-merge file shows up in the merge commit. t.Views().Main(). Content(Contains(postMergeFilename)). Content(Contains("++" + postMergeFileContent)) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go000066400000000000000000000022541500612110400335460ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AmendNonHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Tries to amend a commit that is not the head while already rebasing, resulting in an error message", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateNCommits(3) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 02"), Contains("commit 01"), ) for _, commit := range []string{"commit 01", "commit 03"} { t.Views().Commits(). NavigateToLine(Contains(commit)). Press(keys.Commits.AmendToCommit) t.ExpectToast(Contains("Can't perform this action during a rebase")) } }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go000066400000000000000000000044261500612110400310560ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Delete an update-ref item from the rebase todo list", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. NewBranch("branch1"). CreateNCommits(3). NewBranch("branch2"). CreateNCommitsStartingAt(3, 4) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). NavigateToLine(Contains("commit 01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 06"), Contains("pick").Contains("CI commit 05"), Contains("pick").Contains("CI commit 04"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit 03"), Contains("pick").Contains("CI commit 02"), Contains("--- Commits ---"), Contains("CI ◯ commit 01"), ). NavigateToLine(Contains("update-ref")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Drop commit")). Content(Contains("Are you sure you want to delete the selected update-ref todo(s)?")). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 06"), Contains("pick").Contains("CI commit 05"), Contains("pick").Contains("CI commit 04"), Contains("pick").Contains("CI commit 03").IsSelected(), Contains("pick").Contains("CI commit 02"), Contains("--- Commits ---"), Contains("CI ◯ commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Universal.Remove). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("CI ◯ commit 06"), Contains("CI ◯ commit 05"), Contains("CI ◯ commit 04"), Contains("CI ◯ commit 03"), // No star on this commit, so there's no branch head here Contains("CI ◯ commit 01"), ) t.Views().Branches(). Lines( Contains("branch2"), Contains("branch1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go000066400000000000000000000034661500612110400341550ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DontShowBranchHeadsForTodoItems = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Check that branch heads are shown for normal commits during interactive rebase, but not for todo items", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. NewBranch("branch1"). CreateNCommits(2). NewBranch("branch2"). CreateNCommitsStartingAt(4, 3). NewBranch("branch3"). CreateNCommitsStartingAt(3, 7) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI commit 09"), Contains("CI commit 08"), Contains("CI commit 07"), Contains("CI * commit 06"), Contains("CI commit 05"), Contains("CI commit 04"), Contains("CI commit 03"), Contains("CI * commit 02"), Contains("CI commit 01"), ). NavigateToLine(Contains("commit 04")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 09"), Contains("pick").Contains("CI commit 08"), Contains("pick").Contains("CI commit 07"), Contains("update-ref").Contains("branch2"), Contains("pick").Contains("CI commit 06"), // no star on this entry, even though branch2 points to it Contains("pick").Contains("CI commit 05"), Contains("--- Commits ---"), Contains("CI commit 04"), Contains("CI commit 03"), Contains("CI * commit 02"), // this star is fine though Contains("CI commit 01"), ) }, }) drop_commit_in_copied_branch_with_update_ref.go000066400000000000000000000030351500612110400354000ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebasepackage interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DropCommitInCopiedBranchWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Drops a commit in a branch that is a copy of another branch, and verify that the other branch is left alone", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. NewBranch("branch1"). CreateNCommits(3). NewBranch("branch2") shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI * commit 03").IsSelected(), Contains("CI commit 02"), Contains("CI commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Drop commit")). Content(Equals("Are you sure you want to drop the selected commit(s)?")). Confirm() }). Lines( Contains("CI commit 03"), // no start on this commit because branch1 is no longer pointing to it Contains("CI commit 01"), ) t.Views().Branches(). Focus(). NavigateToLine(Contains("branch1")). PressPrimaryAction() t.Views().Commits().Lines( Contains("CI commit 03"), Contains("CI commit 02"), Contains("CI commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/drop_merge_commit.go000066400000000000000000000027371500612110400300670ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var DropMergeCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Drops a merge commit outside of an interactive rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.CreateMergeCommit(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI ⏣─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains("CI │ ◯ * second-change-branch unrelated change"), Contains("CI │ ◯ second change"), Contains("CI ◯ │ first change"), Contains("CI ◯─╯ * original"), Contains("CI ◯ three"), Contains("CI ◯ two"), Contains("CI ◯ one"), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Drop commit")). Content(Equals("Are you sure you want to drop the selected merge commit? Note that it will also drop all the commits that were merged in by it.")). Confirm() }). Lines( Contains("CI ◯ first change").IsSelected(), Contains("CI ◯ * original"), Contains("CI ◯ three"), Contains("CI ◯ two"), Contains("CI ◯ one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go000066400000000000000000000041171500612110400331600ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Drops a commit during interactive rebase when there is an update-ref in the git-rebase-todo file", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.MainBranches = []string{"master"} config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). NewBranch("branch1"). CreateNCommitsStartingAt(3, 2). NewBranch("branch2"). CreateNCommitsStartingAt(3, 5) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI commit 07").IsSelected(), Contains("CI commit 06"), Contains("CI commit 05"), Contains("CI * commit 04"), Contains("CI commit 03"), Contains("CI commit 02"), Contains("CI commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 07"), Contains("pick").Contains("CI commit 06"), Contains("pick").Contains("CI commit 05"), Contains("update-ref").Contains("branch1").DoesNotContain("*"), Contains("pick").Contains("CI commit 04"), Contains("pick").Contains("CI commit 03"), Contains("--- Commits ---"), Contains("CI commit 02").IsSelected(), Contains("CI commit 01"), ). Tap(func() { t.Views().Main().Content(Contains("commit 02")) }). NavigateToLine(Contains("commit 06")). Press(keys.Universal.Remove) t.Common().ContinueRebase() t.Views().Commits(). IsFocused(). Lines( Contains("CI commit 07"), Contains("CI commit 05"), Contains("CI * commit 04"), Contains("CI commit 03"), Contains("CI commit 02"), Contains("CI commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go000066400000000000000000000017251500612110400325000ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DropWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Drops a commit with the 'core.commentChar' option set to a custom character", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.SetConfig("core.commentChar", ";") shell.CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( Contains("commit 02").IsSelected(), Contains("commit 01"), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Drop commit")). Content(Equals("Are you sure you want to drop the selected commit(s)?")). Confirm() }). Lines( Contains("commit 01").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go000066400000000000000000000025771500612110400303410ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EditAndAutoAmend = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Edit a commit, make a change and stage it, then continue the rebase to auto-amend the commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(3) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 02").IsSelected(), Contains("commit 01"), ) t.Shell().CreateFile("fixup-file", "fixup content") t.Views().Files(). Focus(). Press(keys.Files.RefreshFiles). Lines( Contains("??").Contains("fixup-file").IsSelected(), ). PressPrimaryAction() t.Common().ContinueRebase() t.Views().Commits(). Focus(). Lines( Contains("commit 03"), Contains("commit 02").IsSelected(), Contains("commit 01"), ) t.Views().Main(). Content(Contains("fixup content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/edit_first_commit.go000066400000000000000000000017321500612110400300720ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EditFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Edits the first commit, just to show that it's possible", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 02"), Contains("--- Commits ---"), Contains("commit 01").IsSelected(), ). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("commit 02"), Contains("commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go000066400000000000000000000042051500612110400334030ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EditLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Edit and amend the last commit of a branch in a stack of branches, and ensure that it doesn't break the stack", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.MainBranches = []string{"master"} config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). NewBranch("branch1"). CreateNCommitsStartingAt(2, 2). NewBranch("branch2"). CreateNCommitsStartingAt(2, 4) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI commit 05").IsSelected(), Contains("CI commit 04"), Contains("CI * commit 03"), Contains("CI commit 02"), Contains("CI commit 01"), ). NavigateToLine(Contains("commit 03")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 05"), Contains("pick").Contains("CI commit 04"), Contains("update-ref").Contains("branch1"), Contains("--- Commits ---"), Contains("CI * commit 03").IsSelected(), Contains("CI commit 02"), Contains("CI commit 01"), ) t.Shell().CreateFile("fixup-file", "fixup content") t.Views().Files(). Focus(). Press(keys.Files.RefreshFiles). Lines( Contains("??").Contains("fixup-file").IsSelected(), ). PressPrimaryAction(). Press(keys.Files.AmendLastCommit) t.ExpectPopup().Confirmation(). Title(Equals("Amend last commit")). Content(Contains("Are you sure you want to amend last commit?")). Confirm() t.Common().ContinueRebase() t.Views().Commits(). Focus(). Lines( Contains("CI commit 05"), Contains("CI commit 04"), Contains("CI * commit 03"), Contains("CI commit 02"), Contains("CI commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go000066400000000000000000000020261500612110400334500ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EditNonTodoCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Tries to edit a non-todo commit while already rebasing, resulting in an error message", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 02").IsSelected(), Contains("commit 01"), ). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("--- Commits ---"), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Universal.Edit) t.ExpectToast(Contains("Disabled: When rebasing, this action only works on a selection of TODO commits.")) }, }) edit_range_select_down_to_merge_outside_rebase.go000066400000000000000000000031341500612110400357320ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebasepackage interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var EditRangeSelectDownToMergeOutsideRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Select a range of commits (the last one being a merge commit) to edit outside of a rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.CreateMergeCommit(shell) shell.CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). TopLines( Contains("CI ◯ commit 02").IsSelected(), Contains("CI ◯ commit 01"), Contains("Merge branch 'second-change-branch' into first-change-branch"), ). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("edit CI commit 02").IsSelected(), Contains("edit CI commit 01").IsSelected(), Contains("--- Commits ---").IsSelected(), Contains(" CI ⏣─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains(" CI │ ◯ * second-change-branch unrelated change"), Contains(" CI │ ◯ second change"), Contains(" CI ◯ │ first change"), Contains(" CI ◯─╯ * original"), Contains(" CI ◯ three"), Contains(" CI ◯ two"), Contains(" CI ◯ one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go000066400000000000000000000037311500612110400331040ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var EditRangeSelectOutsideRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Select a range of commits to edit outside of a rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shared.CreateMergeCommit(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). TopLines( Contains("Merge branch 'second-change-branch' into first-change-branch").IsSelected(), ). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Lines( Contains("CI ⏣─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains("CI │ ◯ * second-change-branch unrelated change").IsSelected(), Contains("CI │ ◯ second change").IsSelected(), Contains("CI ◯ │ first change").IsSelected(), Contains("CI ◯─╯ * original").IsSelected(), Contains("CI ◯ three").IsSelected(), Contains("CI ◯ two"), Contains("CI ◯ one"), ). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("merge CI Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains("edit CI first change").IsSelected(), Contains("edit CI * second-change-branch unrelated change").IsSelected(), Contains("edit CI second change").IsSelected(), Contains("edit CI * original").IsSelected(), Contains("--- Commits ---").IsSelected(), Contains(" CI ◯ three").IsSelected(), Contains(" CI ◯ two"), Contains(" CI ◯ one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go000066400000000000000000000027011500612110400307010ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EditTheConflCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Swap two commits, causing a conflict; then try to interact with the 'confl' commit, which results in an error.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "one") shell.Commit("commit one") shell.UpdateFileAndAdd("myfile", "two") shell.Commit("commit two") shell.UpdateFileAndAdd("myfile", "three") shell.Commit("commit three") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit three").IsSelected(), Contains("commit two"), Contains("commit one"), ). Press(keys.Commits.MoveDownCommit). Tap(func() { t.Common().AcknowledgeConflicts() }). Focus(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit two"), Contains("pick").Contains("<-- CONFLICT --- commit three"), Contains("--- Commits ---"), Contains("commit one"), ). NavigateToLine(Contains("<-- CONFLICT --- commit three")). Press(keys.Commits.RenameCommit) t.ExpectToast(Contains("Disabled: Rewording commits while interactively rebasing is not currently supported")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/fixup_first_commit.go000066400000000000000000000015751500612110400303050ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FixupFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Tries to fixup the first commit, which results in an error message", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { t.ExpectToast(Equals("Disabled: There's no commit below to squash into")) }). Lines( Contains("commit 02"), Contains("commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/fixup_second_commit.go000066400000000000000000000031401500612110400304170ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FixupSecondCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Fixup the second commit into the first (initial)", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateFileAndAdd("file1.txt", "File1 Content\n").Commit("First Commit"). CreateFileAndAdd("file2.txt", "Fixup Content\n").Commit("Fixup Commit Message"). CreateFileAndAdd("file3.txt", "File3 Content\n").Commit("Third Commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("Third Commit"), Contains("Fixup Commit Message"), Contains("First Commit"), ). NavigateToLine(Contains("Fixup Commit Message")). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Fixup")). Content(Equals("Are you sure you want to 'fixup' the selected commit(s) into the commit below?")). Confirm() }). Lines( Contains("Third Commit"), Contains("First Commit").IsSelected(), ) t.Views().Main(). // Make sure that the resulting commit message doesn't contain the // message of the fixup commit; compare this to // squash_down_second_commit.go, where it does. Content(Contains("First Commit")). Content(DoesNotContain("Fixup Commit Message")). Content(Contains("+File1 Content")). Content(Contains("+Fixup Content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go000066400000000000000000000024261500612110400335510ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var InteractiveRebaseOfCopiedBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Check that interactively rebasing a branch that is a copy of another branch doesn't affect the original branch", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. NewBranch("branch1"). CreateNCommits(3). NewBranch("branch2") shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI * commit 03"), Contains("CI commit 02"), Contains("CI commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), // No update-ref todo for branch1 here, even though command-line git would have added it Contains("pick").Contains("CI commit 03"), Contains("pick").Contains("CI commit 02"), Contains("--- Commits ---"), Contains("CI commit 01"), ) }, }) interactive_rebase_with_conflict_for_edit_command.go000066400000000000000000000034431500612110400364330ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebasepackage interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var InteractiveRebaseWithConflictForEditCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rebase a branch interactively, and edit a commit that will conflict", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFileAndAdd("file.txt", "master content") shell.Commit("master commit") shell.NewBranchFrom("branch", "master^") shell.CreateNCommits(3) shell.CreateFileAndAdd("file.txt", "branch content") shell.Commit("this will conflict") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("this will conflict").IsSelected(), Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), Contains("initial commit"), ) t.Views().Branches(). Focus(). NavigateToLine(Contains("master")). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'branch'")). Select(Contains("Interactive rebase")). Confirm() t.Views().Commits(). IsFocused(). NavigateToLine(Contains("this will conflict")). Press(keys.Universal.Edit) t.Common().ContinueRebase() t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). Cancel() t.Views().Commits(). Lines( Contains("--- Pending rebase todos ---"), Contains("edit").Contains("<-- CONFLICT --- this will conflict").IsSelected(), Contains("--- Commits ---"), Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), Contains("master commit"), Contains("initial commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go000066400000000000000000000163021500612110400311720ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Do various things with range selection in the commits view when mid-rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(10) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). TopLines( Contains("commit 10").IsSelected(), ). NavigateToLine(Contains("commit 05")). // Start a rebase Press(keys.Universal.Edit). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("pick").Contains("commit 07"), Contains("pick").Contains("commit 06"), Contains("--- Commits ---"), Contains("commit 05").IsSelected(), Contains("commit 04"), ). SelectPreviousItem(). // perform various actions on a range of commits Press(keys.Universal.RangeSelectUp). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("pick").Contains("commit 07").IsSelected(), Contains("pick").Contains("commit 06").IsSelected(), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Press(keys.Commits.MarkCommitAsFixup). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("fixup").Contains("commit 07").IsSelected(), Contains("fixup").Contains("commit 06").IsSelected(), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Press(keys.Commits.PickCommit). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("pick").Contains("commit 07").IsSelected(), Contains("pick").Contains("commit 06").IsSelected(), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Press(keys.Universal.Edit). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("edit").Contains("commit 07").IsSelected(), Contains("edit").Contains("commit 06").IsSelected(), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Press(keys.Commits.SquashDown). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("squash").Contains("commit 07").IsSelected(), Contains("squash").Contains("commit 06").IsSelected(), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Press(keys.Commits.MoveDownCommit). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("squash").Contains("commit 07").IsSelected(), Contains("squash").Contains("commit 06").IsSelected(), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Press(keys.Commits.MoveUpCommit). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("squash").Contains("commit 07").IsSelected(), Contains("squash").Contains("commit 06").IsSelected(), Contains("pick").Contains("commit 08"), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Press(keys.Commits.MoveUpCommit). TopLines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 10"), Contains("squash").Contains("commit 07").IsSelected(), Contains("squash").Contains("commit 06").IsSelected(), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Press(keys.Commits.MoveUpCommit). TopLines( Contains("--- Pending rebase todos ---"), Contains("squash").Contains("commit 07").IsSelected(), Contains("squash").Contains("commit 06").IsSelected(), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). Press(keys.Commits.MoveUpCommit). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). TopLines( Contains("--- Pending rebase todos ---"), Contains("squash").Contains("commit 07").IsSelected(), Contains("squash").Contains("commit 06").IsSelected(), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08"), Contains("--- Commits ---"), Contains("commit 05"), Contains("commit 04"), ). // Verify we can't perform an action on a range that includes both // TODO and non-TODO commits NavigateToLine(Contains("commit 08")). Press(keys.Universal.RangeSelectDown). TopLines( Contains("--- Pending rebase todos ---"), Contains("squash").Contains("commit 07"), Contains("squash").Contains("commit 06"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08").IsSelected(), Contains("--- Commits ---").IsSelected(), Contains("commit 05").IsSelected(), Contains("commit 04"), ). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { t.ExpectToast(Contains("Disabled: When rebasing, this action only works on a selection of TODO commits.")) }). TopLines( Contains("--- Pending rebase todos ---"), Contains("squash").Contains("commit 07"), Contains("squash").Contains("commit 06"), Contains("pick").Contains("commit 10"), Contains("pick").Contains("commit 09"), Contains("pick").Contains("commit 08").IsSelected(), Contains("--- Commits ---").IsSelected(), Contains("commit 05").IsSelected(), Contains("commit 04"), ). // continue the rebase Tap(func() { t.Common().ContinueRebase() }). TopLines( Contains("commit 10"), Contains("commit 09"), Contains("commit 08"), Contains("commit 05"), // selected indexes are retained, though we may want to clear it // in future (not sure what the best behaviour is right now) Contains("commit 04").IsSelected(), Contains("commit 03").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/move.go000066400000000000000000000045471500612110400253430ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Move = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Directly move a commit all the way down and all the way back up", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateNCommits(4) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 04").IsSelected(), Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("commit 03"), Contains("commit 04").IsSelected(), Contains("commit 02"), Contains("commit 01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 04").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), Contains("commit 04").IsSelected(), ). // assert nothing happens upon trying to move beyond the last commit Press(keys.Commits.MoveDownCommit). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), Contains("commit 04").IsSelected(), ). Press(keys.Commits.MoveUpCommit). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 04").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.MoveUpCommit). Lines( Contains("commit 03"), Contains("commit 04").IsSelected(), Contains("commit 02"), Contains("commit 01"), ). Press(keys.Commits.MoveUpCommit). Lines( Contains("commit 04").IsSelected(), Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). // assert nothing happens upon trying to move beyond the first commit Press(keys.Commits.MoveUpCommit). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( Contains("commit 04").IsSelected(), Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ) }, }) move_across_branch_boundary_outside_rebase.go000066400000000000000000000024761500612110400351320ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebasepackage interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveAcrossBranchBoundaryOutsideRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a commit across a branch boundary in a stack of branches", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.MainBranches = []string{"master"} config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). NewBranch("branch1"). CreateNCommitsStartingAt(2, 2). NewBranch("branch2"). CreateNCommitsStartingAt(2, 4) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI commit 05").IsSelected(), Contains("CI commit 04"), Contains("CI * commit 03"), Contains("CI commit 02"), Contains("CI commit 01"), ). NavigateToLine(Contains("commit 04")). Press(keys.Commits.MoveDownCommit). Lines( Contains("CI commit 05"), Contains("CI * commit 03"), Contains("CI commit 04").IsSelected(), Contains("CI commit 02"), Contains("CI commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/move_in_rebase.go000066400000000000000000000064651500612110400273530ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Via a single interactive rebase move a commit all the way up then back down then slightly back up again and apply the change", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateNCommits(4) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 04").IsSelected(), Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 04"), Contains("commit 03"), Contains("commit 02"), Contains("--- Commits ---"), Contains("commit 01").IsSelected(), ). SelectPreviousItem(). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 04"), Contains("commit 02").IsSelected(), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 01"), ). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 02").IsSelected(), Contains("commit 04"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 01"), ). // assert we can't move past the top Press(keys.Commits.MoveUpCommit). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 02").IsSelected(), Contains("commit 04"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 04"), Contains("commit 02").IsSelected(), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 04"), Contains("commit 03"), Contains("commit 02").IsSelected(), Contains("--- Commits ---"), Contains("commit 01"), ). // assert we can't move past the bottom Press(keys.Commits.MoveDownCommit). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 04"), Contains("commit 03"), Contains("commit 02").IsSelected(), Contains("--- Commits ---"), Contains("commit 01"), ). // move it back up one so that we land in a different order than we started with Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 04"), Contains("commit 02").IsSelected(), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("commit 04"), Contains("commit 02").IsSelected(), Contains("commit 03"), Contains("commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go000066400000000000000000000037301500612110400305570ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move an update-ref item in the rebase todo list", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. NewBranch("branch1"). CreateNCommits(3). NewBranch("branch2"). CreateNCommitsStartingAt(3, 4) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). NavigateToLine(Contains("commit 01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 06"), Contains("pick").Contains("CI commit 05"), Contains("pick").Contains("CI commit 04"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit 03"), Contains("pick").Contains("CI commit 02"), Contains("--- Commits ---"), Contains("CI ◯ commit 01"), ). NavigateToLine(Contains("update-ref")). Press(keys.Commits.MoveUpCommit). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 06"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit 05"), Contains("pick").Contains("CI commit 04"), Contains("pick").Contains("CI commit 03"), Contains("pick").Contains("CI commit 02"), Contains("--- Commits ---"), Contains("CI ◯ commit 01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("CI ◯ commit 06"), Contains("CI ◯ * commit 05"), Contains("CI ◯ commit 04"), Contains("CI ◯ commit 03"), Contains("CI ◯ commit 02"), Contains("CI ◯ commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go000066400000000000000000000017141500612110400325000ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Directly moves a commit down and back up with the 'core.commentChar' option set to a custom character", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.SetConfig("core.commentChar", ";") shell.CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( Contains("commit 02").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("commit 01"), Contains("commit 02").IsSelected(), ). Press(keys.Commits.MoveUpCommit). Lines( Contains("commit 02").IsSelected(), Contains("commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go000066400000000000000000000101521500612110400320720ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Do various things with range selection in the commits view when outside rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(10) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). TopLines( Contains("commit 10").IsSelected(), ). Press(keys.Universal.RangeSelectDown). TopLines( Contains("commit 10").IsSelected(), Contains("commit 09").IsSelected(), Contains("commit 08"), ). // Drop commits Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Drop commit")). Content(Contains("Are you sure you want to drop the selected commit(s)?")). Confirm() }). TopLines( Contains("commit 08").IsSelected(), Contains("commit 07"), ). Press(keys.Universal.RangeSelectDown). TopLines( Contains("commit 08").IsSelected(), Contains("commit 07").IsSelected(), Contains("commit 06"), ). // Squash commits Press(keys.Commits.SquashDown). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Squash")). Content(Contains("Are you sure you want to squash the selected commit(s) into the commit below?")). Confirm() }). TopLines( Contains("commit 06").IsSelected(), Contains("commit 05"), Contains("commit 04"), ). // Verify commit messages are concatenated Tap(func() { t.Views().Main(). ContainsLines( Contains("commit 06"), AnyString(), Contains("commit 07"), AnyString(), Contains("commit 08"), ) }). // Fixup commits Press(keys.Universal.RangeSelectDown). TopLines( Contains("commit 06").IsSelected(), Contains("commit 05").IsSelected(), Contains("commit 04"), ). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Fixup")). Content(Contains("Are you sure you want to 'fixup' the selected commit(s) into the commit below?")). Confirm() }). TopLines( Contains("commit 04").IsSelected(), Contains("commit 03"), Contains("commit 02"), ). // Verify commit messages are dropped Tap(func() { t.Views().Main(). Content( Contains("commit 04"). DoesNotContain("commit 06"). DoesNotContain("commit 05"), ) }). Press(keys.Universal.RangeSelectDown). TopLines( Contains("commit 04").IsSelected(), Contains("commit 03").IsSelected(), Contains("commit 02"), ). // Move commits Press(keys.Commits.MoveDownCommit). TopLines( Contains("commit 02"), Contains("commit 04").IsSelected(), Contains("commit 03").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.MoveDownCommit). TopLines( Contains("commit 02"), Contains("commit 01"), Contains("commit 04").IsSelected(), Contains("commit 03").IsSelected(), ). Press(keys.Commits.MoveDownCommit). TopLines( Contains("commit 02"), Contains("commit 01"), Contains("commit 04").IsSelected(), Contains("commit 03").IsSelected(), ). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Press(keys.Commits.MoveUpCommit). TopLines( Contains("commit 02"), Contains("commit 04").IsSelected(), Contains("commit 03").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.MoveUpCommit). TopLines( Contains("commit 04").IsSelected(), Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), ). Press(keys.Commits.MoveUpCommit). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). TopLines( Contains("commit 04").IsSelected(), Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/pick_rescheduled.go000066400000000000000000000031001500612110400276520ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PickRescheduled = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Makes a pick during a rebase fail because it would overwrite an untracked file", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "1\n").Commit("one") shell.UpdateFileAndAdd("file2", "2\n").Commit("two") shell.UpdateFileAndAdd("file3", "3\n").Commit("three") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ). NavigateToLine(Contains("one")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("three"), Contains("pick").Contains("two"), Contains("--- Commits ---"), Contains("one").IsSelected(), ). Tap(func() { t.Shell().CreateFile("file3", "other content\n") t.Common().ContinueRebase() t.ExpectPopup().Alert().Title(Equals("Error")). Content(Contains("The following untracked working tree files would be overwritten by merge"). Contains("Please move or remove them before you merge.")). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("three"), Contains("--- Commits ---"), Contains("two"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/quick_start.go000066400000000000000000000074701500612110400267240ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var QuickStart = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Quick-starts an interactive rebase in several contexts", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // we're going to test the following: // * quick start from main fails // * quick start from feature branch starts from main // * quick start from branch with merge commit starts from merge commit shell.NewBranch("main") shell.EmptyCommit("initial commit") shell.EmptyCommit("last main commit") shell.NewBranch("feature-branch") shell.NewBranch("branch-to-merge") shell.NewBranch("branch-with-merge-commit") shell.Checkout("feature-branch") shell.EmptyCommit("feature-branch one") shell.EmptyCommit("feature-branch two") shell.Checkout("branch-to-merge") shell.EmptyCommit("branch-to-merge one") shell.EmptyCommit("branch-to-merge two") shell.Checkout("branch-with-merge-commit") shell.EmptyCommit("branch-with-merge one") shell.EmptyCommit("branch-with-merge two") shell.Merge("branch-to-merge") shell.EmptyCommit("branch-with-merge three") shell.Checkout("main") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("last main commit"), Contains("initial commit"), ). // Verify we can't quick start from main Press(keys.Commits.StartInteractiveRebase) t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Equals("Cannot start interactive rebase: the HEAD commit is a merge commit or is present on the main branch, so there is no appropriate base commit to start the rebase from. You can start an interactive rebase from a specific commit by selecting the commit and pressing `e`.")). Confirm() t.Views().Branches(). Focus(). NavigateToLine(Contains("feature-branch")). Press(keys.Universal.Select) t.Views().Commits(). Focus(). Lines( Contains("feature-branch two").IsSelected(), Contains("feature-branch one"), Contains("last main commit"), Contains("initial commit"), ). // Verify quick start picks the last commit on the main branch Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), Contains("feature-branch two").IsSelected(), Contains("feature-branch one"), Contains("--- Commits ---"), Contains("last main commit"), Contains("initial commit"), ). // Try again, verify we fail because we're already rebasing Press(keys.Commits.StartInteractiveRebase) t.ExpectToast(Equals("Disabled: Can't perform this action during a rebase")) t.Common().AbortRebase() // Verify if a merge commit is present on the branch we start from there t.Views().Branches(). Focus(). NavigateToLine(Contains("branch-with-merge-commit")). Press(keys.Universal.Select) t.Views().Commits(). Focus(). Lines( Contains("branch-with-merge three").IsSelected(), Contains("Merge branch 'branch-to-merge'"), Contains("branch-to-merge two"), Contains("branch-to-merge one"), Contains("branch-with-merge two"), Contains("branch-with-merge one"), Contains("last main commit"), Contains("initial commit"), ). Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), Contains("branch-with-merge three").IsSelected(), Contains("--- Commits ---"), Contains("Merge branch 'branch-to-merge'"), Contains("branch-to-merge two"), Contains("branch-to-merge one"), Contains("branch-with-merge two"), Contains("branch-with-merge one"), Contains("last main commit"), Contains("initial commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go000066400000000000000000000032201500612110400317620ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var QuickStartKeepSelection = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Starts an interactive rebase and checks that the same commit stays selected", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.MainBranches = []string{"master"} config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). NewBranch("branch1"). CreateNCommitsStartingAt(3, 2). NewBranch("branch2"). CreateNCommitsStartingAt(3, 5) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI commit 07").IsSelected(), Contains("CI commit 06"), Contains("CI commit 05"), Contains("CI * commit 04"), Contains("CI commit 03"), Contains("CI commit 02"), Contains("CI commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 07"), Contains("pick").Contains("CI commit 06"), Contains("pick").Contains("CI commit 05"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit 04"), Contains("pick").Contains("CI commit 03"), Contains("CI commit 02").IsSelected(), Contains("--- Commits ---"), Contains("CI commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go000066400000000000000000000035111500612110400331410ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var QuickStartKeepSelectionRange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Starts an interactive rebase and checks that the same commit range stays selected", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.MainBranches = []string{"master"} config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). NewBranch("branch1"). CreateNCommitsStartingAt(2, 2). NewBranch("branch2"). CreateNCommitsStartingAt(2, 4). NewBranch("branch3"). CreateNCommitsStartingAt(2, 6) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). NavigateToLine(Contains("commit 04")). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Lines( Contains("CI commit 07"), Contains("CI commit 06"), Contains("CI * commit 05"), Contains("CI commit 04").IsSelected(), Contains("CI * commit 03").IsSelected(), Contains("CI commit 02").IsSelected(), Contains("CI commit 01"), ). Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), Contains("CI commit 07"), Contains("CI commit 06"), Contains("update-ref").Contains("branch2"), Contains("CI commit 05"), Contains("CI commit 04").IsSelected(), Contains("update-ref").Contains("branch1").IsSelected(), Contains("CI commit 03").IsSelected(), Contains("CI commit 02").IsSelected(), Contains("--- Commits ---"), Contains("CI commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/rebase.go000066400000000000000000000105661500612110400256340ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Rebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Begins an interactive rebase, then fixups, drops, and squashes some commits", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.EmptyCommit("first commit to edit") shell.EmptyCommit("commit to squash") shell.EmptyCommit("second commit to edit") shell.EmptyCommit("commit to drop") shell.CreateFileAndAdd("fixup-commit-file", "fixup-commit-file") shell.Commit("commit to fixup") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit to fixup"), Contains("commit to drop"), Contains("second commit to edit"), Contains("commit to squash"), Contains("first commit to edit"), Contains("initial commit"), ). NavigateToLine(Contains("first commit to edit")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("pick.*second commit to edit"), MatchesRegexp("pick.*commit to squash"), Contains("--- Commits ---"), Contains("first commit to edit").IsSelected(), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Commits.SquashDown). Lines( Contains("--- Pending rebase todos ---"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("pick.*second commit to edit"), MatchesRegexp("squash.*commit to squash").IsSelected(), Contains("--- Commits ---"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("edit.*second commit to edit").IsSelected(), MatchesRegexp("squash.*commit to squash"), Contains("--- Commits ---"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Universal.Remove). Lines( Contains("--- Pending rebase todos ---"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("drop.*commit to drop").IsSelected(), MatchesRegexp("edit.*second commit to edit"), MatchesRegexp("squash.*commit to squash"), Contains("--- Commits ---"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Commits.MarkCommitAsFixup). Lines( Contains("--- Pending rebase todos ---"), MatchesRegexp("fixup.*commit to fixup").IsSelected(), MatchesRegexp("drop.*commit to drop"), MatchesRegexp("edit.*second commit to edit"), MatchesRegexp("squash.*commit to squash"), Contains("--- Commits ---"), Contains("first commit to edit"), Contains("initial commit"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("--- Pending rebase todos ---"), MatchesRegexp("fixup.*commit to fixup").IsSelected(), MatchesRegexp("drop.*commit to drop"), Contains("--- Commits ---"), Contains("second commit to edit"), MatchesRegexp("first commit to edit"), Contains("initial commit"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("second commit to edit").IsSelected(), Contains("first commit to edit"), Contains("initial commit"), ). Tap(func() { // commit 4 was squashed into 6 so we assert that their messages have been concatenated t.Views().Main().Content( Contains("second commit to edit"). // file from fixup commit is present Contains("fixup-commit-file"). // but message is not (because it's a fixup, not a squash) DoesNotContain("commit to fixup"), ) }). SelectNextItem(). Tap(func() { // commit 4 was squashed into 6 so we assert that their messages have been concatenated t.Views().Main().Content( Contains("first commit to edit"). // message from squashed commit has been concatenated with message other commit Contains("commit to squash"), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/rebase_with_commit_that_becomes_empty.go000066400000000000000000000027211500612110400341640ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RebaseWithCommitThatBecomesEmpty = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Performs a rebase involving a commit that becomes empty during the rebase, and gets dropped.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") // It is important that we create two separate commits for the two // changes to the file, but only one commit for the same changes on our // branch; otherwise, the commit would be discarded at the start of the // rebase already. shell.CreateFileAndAdd("file", "change 1\n") shell.Commit("master change 1") shell.UpdateFileAndAdd("file", "change 1\nchange 2\n") shell.Commit("master change 2") shell.NewBranchFrom("branch", "HEAD^^") shell.CreateFileAndAdd("file", "change 1\nchange 2\n") shell.Commit("branch change") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). NavigateToLine(Contains("master")). Press(keys.Branches.RebaseBranch) t.ExpectPopup().Menu(). Title(Equals("Rebase 'branch'")). Select(Contains("Simple rebase")). Confirm() t.Views().Commits(). Lines( Contains("master change 2"), Contains("master change 1"), Contains("initial commit"), ) }, }) revert_during_rebase_when_stopped_on_edit.go000066400000000000000000000035671500612110400347770ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebasepackage interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Revert a series of commits while stopped in a rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("master commit 1") shell.EmptyCommit("master commit 2") shell.NewBranch("branch") shell.CreateNCommits(4) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 04").IsSelected(), Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), Contains("master commit 2"), Contains("master commit 1"), ). NavigateToLine(Contains("commit 03")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 04"), Contains("--- Commits ---"), Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), Contains("master commit 2"), Contains("master commit 1"), ). SelectNextItem(). Press(keys.Universal.RangeSelectDown). Press(keys.Commits.RevertCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Revert commit")). Content(MatchesRegexp(`Are you sure you want to revert \w+?`)). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit 04"), Contains("--- Commits ---"), Contains(`Revert "commit 01"`), Contains(`Revert "commit 02"`), Contains("commit 03"), Contains("commit 02").IsSelected(), Contains("commit 01").IsSelected(), Contains("master commit 2"), Contains("master commit 1"), ) }, }) revert_multiple_commits_in_interactive_rebase.go000066400000000000000000000073121500612110400356700ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebasepackage interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RevertMultipleCommitsInInteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reverts a range of commits, the first of which conflicts, in the middle of an interactive rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "") shell.Commit("add empty file") shell.CreateFileAndAdd("otherfile", "") shell.Commit("unrelated change 1") shell.CreateFileAndAdd("myfile", "first line\n") shell.Commit("add first line") shell.UpdateFileAndAdd("myfile", "first line\nsecond line\n") shell.Commit("add second line") shell.EmptyCommit("unrelated change 2") shell.EmptyCommit("unrelated change 3") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI ◯ unrelated change 3").IsSelected(), Contains("CI ◯ unrelated change 2"), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ unrelated change 1"), Contains("CI ◯ add empty file"), ). NavigateToLine(Contains("add second line")). Press(keys.Universal.Edit). SelectNextItem(). Press(keys.Universal.RangeSelectDown). Press(keys.Commits.RevertCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Revert commit")). Content(Equals("Are you sure you want to revert the selected commits?")). Confirm() t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). Select(Contains("View conflicts")). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("CI unrelated change 3"), Contains("CI unrelated change 2"), Contains("--- Pending reverts ---"), Contains("revert").Contains("CI unrelated change 1"), Contains("revert").Contains("CI <-- CONFLICT --- add first line"), Contains("--- Commits ---"), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ unrelated change 1"), Contains("CI ◯ add empty file"), ) t.Views().Options().Content(Contains("View revert options: m")) t.Views().Information().Content(Contains("Reverting (Reset)")) t.Views().Files().IsFocused(). Lines( Contains("UU myfile").IsSelected(), ). PressEnter() t.Views().MergeConflicts().IsFocused(). SelectNextItem(). PressPrimaryAction() t.ExpectPopup().Alert(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the revert?")). Confirm() t.Views().Commits(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI unrelated change 3"), Contains("pick").Contains("CI unrelated change 2"), Contains("--- Commits ---"), Contains(`CI ◯ Revert "unrelated change 1"`), Contains(`CI ◯ Revert "add first line"`), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ unrelated change 1"), Contains("CI ◯ add empty file"), ) t.Views().Options().Content(Contains("View rebase options: m")) t.Views().Information().Content(Contains("Rebasing (Reset)")) t.Common().ContinueRebase() t.Views().Commits(). Lines( Contains("CI ◯ unrelated change 3"), Contains("CI ◯ unrelated change 2"), Contains(`CI ◯ Revert "unrelated change 1"`), Contains(`CI ◯ Revert "add first line"`), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ unrelated change 1"), Contains("CI ◯ add empty file"), ) }, }) revert_single_commit_in_interactive_rebase.go000066400000000000000000000070751500612110400351410ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebasepackage interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RevertSingleCommitInInteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reverts a commit that conflicts in the middle of an interactive rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "") shell.Commit("add empty file") shell.CreateFileAndAdd("myfile", "first line\n") shell.Commit("add first line") shell.UpdateFileAndAdd("myfile", "first line\nsecond line\n") shell.Commit("add second line") shell.EmptyCommit("unrelated change 1") shell.EmptyCommit("unrelated change 2") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("CI ◯ unrelated change 2").IsSelected(), Contains("CI ◯ unrelated change 1"), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ add empty file"), ). NavigateToLine(Contains("add second line")). Press(keys.Universal.Edit). SelectNextItem(). Press(keys.Commits.RevertCommit). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Revert commit")). Content(MatchesRegexp(`Are you sure you want to revert \w+?`)). Confirm() t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). Select(Contains("View conflicts")). Cancel() // stay in commits panel }). Lines( Contains("--- Pending rebase todos ---"), Contains("CI unrelated change 2"), Contains("CI unrelated change 1"), Contains("--- Pending reverts ---"), Contains("revert").Contains("CI <-- CONFLICT --- add first line"), Contains("--- Commits ---"), Contains("CI ◯ add second line"), Contains("CI ◯ add first line").IsSelected(), Contains("CI ◯ add empty file"), ). Press(keys.Commits.MoveDownCommit). Tap(func() { t.ExpectToast(Equals("Disabled: This action is not allowed while cherry-picking or reverting")) }). Press(keys.Universal.Remove). Tap(func() { t.ExpectToast(Equals("Disabled: This action is not allowed while cherry-picking or reverting")) }) t.Views().Options().Content(Contains("View revert options: m")) t.Views().Information().Content(Contains("Reverting (Reset)")) t.Views().Files().Focus(). Lines( Contains("UU myfile").IsSelected(), ). PressEnter() t.Views().MergeConflicts().IsFocused(). SelectNextItem(). PressPrimaryAction() t.ExpectPopup().Alert(). Title(Equals("Continue")). Content(Contains("All merge conflicts resolved. Continue the revert?")). Confirm() t.Views().Commits(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI unrelated change 2"), Contains("pick").Contains("CI unrelated change 1"), Contains("--- Commits ---"), Contains(`CI ◯ Revert "add first line"`), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ add empty file"), ) t.Views().Options().Content(Contains("View rebase options: m")) t.Views().Information().Content(Contains("Rebasing (Reset)")) t.Common().ContinueRebase() t.Views().Commits(). Lines( Contains("CI ◯ unrelated change 2"), Contains("CI ◯ unrelated change 1"), Contains(`CI ◯ Revert "add first line"`), Contains("CI ◯ add second line"), Contains("CI ◯ add first line"), Contains("CI ◯ add empty file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go000066400000000000000000000025131500612110400334540ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RewordCommitWithEditorAndFail = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rewords a commit with editor, and fails because an empty commit message is given", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell. CreateNCommits(3). SetConfig("core.editor", "sh -c 'echo .git/COMMIT_EDITMSG'") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Commits.RenameCommitWithEditor). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Reword in editor")). Content(Contains("Are you sure you want to reword this commit in your editor?")). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 02").IsSelected(), Contains("commit 01"), ) t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("exit status 1")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/reword_first_commit.go000066400000000000000000000021221500612110400304410ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // Rewording the first commit is tricky because you can't rebase from its parent commit, // hence having a specific test for this var RewordFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rewords the first commit, just to show that it's possible", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). InitialText(Equals("commit 01")). Clear(). Type("renamed 01"). Confirm() }). Lines( Contains("commit 02"), Contains("renamed 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/reword_last_commit.go000066400000000000000000000016261500612110400302650ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RewordLastCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rewords the last (HEAD) commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 02").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). InitialText(Equals("commit 02")). Clear(). Type("renamed 02"). Confirm() }). Lines( Contains("renamed 02"), Contains("commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go000066400000000000000000000025161500612110400317670ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RewordYouAreHereCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rewords the current HEAD commit in an interactive rebase", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(3) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 02").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). InitialText(Equals("commit 02")). Clear(). Type("renamed 02"). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("renamed 02").IsSelected(), Contains("commit 01"), ) }, }) reword_you_are_here_commit_with_editor.go000066400000000000000000000027021500612110400343060ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebasepackage interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rewords the current HEAD commit in an interactive rebase with editor", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell. CreateNCommits(3). SetConfig("core.editor", "sh -c 'echo renamed 02 >.git/COMMIT_EDITMSG'") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03").IsSelected(), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("commit 02").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.RenameCommitWithEditor). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Reword in editor")). Content(Contains("Are you sure you want to reword this commit in your editor?")). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("commit 03"), Contains("--- Commits ---"), Contains("renamed 02").IsSelected(), Contains("commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/shared.go000066400000000000000000000031011500612110400256240ustar00rootroot00000000000000package interactive_rebase import ( . "github.com/jesseduffield/lazygit/pkg/integration/components" ) func handleConflictsFromSwap(t *TestDriver, expectedCommand string) { t.Common().AcknowledgeConflicts() t.Views().Commits(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("commit two"), Contains(expectedCommand).Contains("<-- CONFLICT --- commit three"), Contains("--- Commits ---"), Contains("commit one"), ) t.Views().Files(). IsFocused(). Lines( Contains("UU myfile"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). TopLines( Contains("<<<<<<< HEAD"), Contains("one"), Contains("======="), Contains("three"), Contains(">>>>>>>"), ). SelectNextItem(). PressPrimaryAction() // pick "three" t.Common().ContinueOnConflictsResolved("rebase") t.Common().AcknowledgeConflicts() t.Views().Files(). IsFocused(). Lines( Contains("UU myfile"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). TopLines( Contains("<<<<<<< HEAD"), Contains("three"), Contains("======="), Contains("two"), Contains(">>>>>>>"), ). SelectNextItem(). PressPrimaryAction() // pick "two" t.Common().ContinueOnConflictsResolved("rebase") t.Views().Commits(). Focus(). Lines( Contains("commit two").IsSelected(), Contains("commit three"), Contains("commit one"), ). Tap(func() { t.Views().Main().Content(Contains("-three").Contains("+two")) }). SelectNextItem(). Tap(func() { t.Views().Main().Content(Contains("-one").Contains("+three")) }) } lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/show_exec_todos.go000066400000000000000000000032021500612110400275540ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Show exec todos in the rebase todo list", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "X", Context: "commits", Command: "git -c core.editor=: rebase -i -x false HEAD^^", }, } }, SetupRepo: func(shell *Shell) { shell. NewBranch("branch1"). CreateNCommits(3) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Press("X"). Tap(func() { t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("Rebasing (2/4)Executing: false")).Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("exec").Contains("false"), Contains("pick").Contains("CI commit 03"), Contains("--- Commits ---"), Contains("CI ◯ commit 02"), Contains("CI ◯ commit 01"), ). Tap(func() { t.Common().ContinueRebase() t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("exit status 1")).Confirm() }). Lines( Contains("--- Pending rebase todos ---"), Contains("--- Commits ---"), Contains("CI ◯ commit 03"), Contains("CI ◯ commit 02"), Contains("CI ◯ commit 01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( Contains("CI ◯ commit 03"), Contains("CI ◯ commit 02"), Contains("CI ◯ commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go000066400000000000000000000016011500612110400314730ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SquashDownFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Tries to squash down the first commit, which results in an error message", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Commits.SquashDown). Tap(func() { t.ExpectToast(Equals("Disabled: There's no commit below to squash into")) }). Lines( Contains("commit 02"), Contains("commit 01"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go000066400000000000000000000022441500612110400316230ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SquashDownSecondCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Squash down the second commit into the first (initial)", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(3) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Commits.SquashDown). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Squash")). Content(Equals("Are you sure you want to squash the selected commit(s) into the commit below?")). Confirm() }). Lines( Contains("commit 03"), Contains("commit 01").IsSelected(), ) t.Views().Main(). Content(Contains(" commit 01\n \n commit 02")). Content(Contains("+file01 content")). Content(Contains("+file02 content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/squash_fixups_above.go000066400000000000000000000027151500612110400304460ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SquashFixupsAbove = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Squashes all fixups above a commit and checks that the selected line stays correct.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(3). CreateFileAndAdd("fixup-file", "fixup content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 02")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Create fixup commit")). Select(Contains("fixup! commit")). Confirm() }). Lines( Contains("fixup! commit 02"), Contains("commit 03"), Contains("commit 02").IsSelected(), Contains("commit 01"), ). Press(keys.Commits.SquashAboveCommits). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Apply fixup commits")). Select(Contains("Above the selected commit")). Confirm() }). Lines( Contains("commit 03"), Contains("commit 02").IsSelected(), Contains("commit 01"), ) t.Views().Main(). Content(Contains("fixup content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go000066400000000000000000000024671500612110400332310ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SquashFixupsAboveFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Squashes all fixups above the first (initial) commit.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateNCommits(2). CreateFileAndAdd("fixup-file", "fixup content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit 02"), Contains("commit 01"), ). NavigateToLine(Contains("commit 01")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Create fixup commit")). Select(Contains("fixup! commit")). Confirm() }). NavigateToLine(Contains("commit 01").DoesNotContain("fixup!")). Press(keys.Commits.SquashAboveCommits). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Apply fixup commits")). Select(Contains("Above the selected commit")). Confirm() }). Lines( Contains("commit 02"), Contains("commit 01").IsSelected(), ) t.Views().Main(). Content(Contains("fixup content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go000066400000000000000000000031121500612110400330270ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Squashes all fixups in the current branch.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. CreateFileAndAdd("file1", "file1"). Commit("master commit"). NewBranch("branch"). // Test the pathological case that the first commit of a branch is a // fixup for the last master commit below it. We _don't_ want this to // be squashed. UpdateFileAndAdd("file1", "changed file1"). Commit("fixup! master commit"). CreateNCommits(2). CreateFileAndAdd("fixup-file", "fixup content"). Commit("fixup! commit 01") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). SelectNextItem(). SelectNextItem(). Lines( Contains("fixup! commit 01"), Contains("commit 02"), Contains("commit 01").IsSelected(), Contains("fixup! master commit"), Contains("master commit"), ). Press(keys.Commits.SquashAboveCommits). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Apply fixup commits")). Select(Contains("In current branch")). Confirm() }). Lines( Contains("commit 02"), Contains("commit 01").IsSelected(), Contains("fixup! master commit"), Contains("master commit"), ) t.Views().Main(). Content(Contains("fixup content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go000066400000000000000000000027771500612110400322750ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SwapInRebaseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Via an edit-triggered rebase, swap two commits, causing a conflict. Then resolve the conflict and continue", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "one") shell.Commit("commit one") shell.UpdateFileAndAdd("myfile", "two") shell.Commit("commit two") shell.UpdateFileAndAdd("myfile", "three") shell.Commit("commit three") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit three").IsSelected(), Contains("commit two"), Contains("commit one"), ). NavigateToLine(Contains("commit one")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit three"), Contains("commit two"), Contains("--- Commits ---"), Contains("commit one").IsSelected(), ). SelectPreviousItem(). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit two").IsSelected(), Contains("commit three"), Contains("--- Commits ---"), Contains("commit one"), ). Tap(func() { t.Common().ContinueRebase() }) handleConflictsFromSwap(t, "pick") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go000066400000000000000000000031771500612110400341170ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SwapInRebaseWithConflictAndEdit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Via an edit-triggered rebase, swap two commits, causing a conflict, then edit the commit that will conflict.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "one") shell.Commit("commit one") shell.UpdateFileAndAdd("myfile", "two") shell.Commit("commit two") shell.UpdateFileAndAdd("myfile", "three") shell.Commit("commit three") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit three").IsSelected(), Contains("commit two"), Contains("commit one"), ). NavigateToLine(Contains("commit one")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit three"), Contains("commit two"), Contains("--- Commits ---"), Contains("commit one").IsSelected(), ). NavigateToLine(Contains("commit two")). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), Contains("commit two").IsSelected(), Contains("commit three"), Contains("--- Commits ---"), Contains("commit one"), ). NavigateToLine(Contains("commit three")). Press(keys.Universal.Edit). SelectPreviousItem(). Tap(func() { t.Common().ContinueRebase() }) handleConflictsFromSwap(t, "edit") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/swap_with_conflict.go000066400000000000000000000017061500612110400302550ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SwapWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Directly swap two commits, causing a conflict. Then resolve the conflict and continue", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "one") shell.Commit("commit one") shell.UpdateFileAndAdd("myfile", "two") shell.Commit("commit two") shell.UpdateFileAndAdd("myfile", "three") shell.Commit("commit three") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit three").IsSelected(), Contains("commit two"), Contains("commit one"), ). Press(keys.Commits.MoveDownCommit) handleConflictsFromSwap(t, "pick") }, }) lazygit-0.50.0+ds1/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go000066400000000000000000000027401500612110400317640ustar00rootroot00000000000000package interactive_rebase import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ViewFilesOfTodoEntries = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Check that files of a pick todo can be viewed, but files of an update-ref todo can't", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). NewBranch("branch1"). CreateNCommitsStartingAt(1, 2). NewBranch("branch2"). CreateNCommitsStartingAt(1, 3) shell.SetConfig("rebase.updateRefs", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("CI commit 03").IsSelected(), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit 02"), Contains("--- Commits ---"), Contains("CI commit 01"), ). Press(keys.Universal.GoInto) t.Views().CommitFiles(). IsFocused(). Lines( Contains("file03.txt"), ). PressEscape() t.Views().Commits(). IsFocused(). NavigateToLine(Contains("update-ref")). Press(keys.Universal.GoInto) t.ExpectToast(Equals("Disabled: Selected item does not have files to view")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/misc/000077500000000000000000000000001500612110400211315ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/misc/confirm_on_quit.go000066400000000000000000000012611500612110400246530ustar00rootroot00000000000000package misc import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ConfirmOnQuit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Quitting with a confirm prompt", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().ConfirmOnQuit = true }, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Press(keys.Universal.Quit) t.ExpectPopup().Confirmation(). Title(Equals("")). Content(Contains("Are you sure you want to quit?")). Confirm() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/misc/copy_to_clipboard.go000066400000000000000000000017701500612110400251600ustar00rootroot00000000000000package misc import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // We're emulating the clipboard by writing to a file called clipboard var CopyToClipboard = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Copy a branch name to the clipboard using custom clipboard command template", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" }, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-a") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("branch-a").IsSelected(), ). Press(keys.Universal.CopyToClipboard) t.ExpectToast(Equals("'branch-a' copied to clipboard")) t.Views().Files(). Focus() t.GlobalPress(keys.Files.RefreshFiles) t.FileSystem().FileContent("clipboard", Equals("branch-a")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/misc/disabled_keybindings.go000066400000000000000000000015231500612110400256160ustar00rootroot00000000000000package misc import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DisabledKeybindings = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Confirms you can disable keybindings by setting them to ", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Keybinding.Universal.PrevItem = "" config.GetUserConfig().Keybinding.Universal.NextItem = "" config.GetUserConfig().Keybinding.Universal.NextTab = "" config.GetUserConfig().Keybinding.Universal.PrevTab = "" }, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Press("") t.Views().Worktrees().IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/misc/initial_open.go000066400000000000000000000012421500612110400241310ustar00rootroot00000000000000package misc import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var InitialOpen = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Confirms a popup appears on first opening Lazygit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().DisableStartupPopups = false }, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.ExpectPopup().Confirmation(). Title(Equals("")). Content(Contains("Thanks for using lazygit!")). Confirm() t.Views().Files().IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/misc/recent_repos_on_launch.go000066400000000000000000000015721500612110400262030ustar00rootroot00000000000000package misc import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // Couldn't find an easy way to actually reproduce the situation of opening outside a repo, // so I'm introducing a hacky env var to force lazygit to show the recent repos menu upon opening. var RecentReposOnLaunch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "When opening to a menu, focus is correctly given to the menu", ExtraCmdArgs: []string{}, ExtraEnvVars: map[string]string{ "SHOW_RECENT_REPOS": "true", }, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.ExpectPopup().Menu(). Title(Equals("Recent repositories")). Select(Contains("Cancel")). Confirm() t.Views().Files().IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/000077500000000000000000000000001500612110400231525ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/apply.go000066400000000000000000000026651500612110400246370ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Apply = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Apply a custom patch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-a") shell.CreateFileAndAdd("file1", "first line\n") shell.Commit("first commit") shell.NewBranch("branch-b") shell.UpdateFileAndAdd("file1", "first line\nsecond line\n") shell.Commit("update") shell.Checkout("branch-a") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("branch-a").IsSelected(), Contains("branch-b"), ). Press(keys.Universal.NextItem). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("update").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("M file1").IsSelected(), ). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content(Contains("second line")) t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`)) t.Views().Files(). Focus(). Lines( Contains("file1").IsSelected(), ) t.Views().Main(). Content(Contains("second line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/apply_in_reverse.go000066400000000000000000000023421500612110400270500ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ApplyInReverse = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Apply a custom patch in reverse", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content\n") shell.CreateFileAndAdd("file2", "file2 content\n") shell.Commit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit").IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" A file1"), Equals(" A file2"), ). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content(Contains("+file1 content")) t.Common().SelectPatchOption(Contains("Apply patch in reverse")) t.Views().Files(). Focus(). Lines( Contains("D").Contains("file1").IsSelected(), ) t.Views().Main(). Content(Contains("-file1 content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go000066400000000000000000000052031500612110400317630ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ApplyInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Apply a custom patch in reverse, resulting in a conflict", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content\n") shell.CreateFileAndAdd("file2", "file2 content\n") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "file1 content\nmore file1 content\n") shell.UpdateFileAndAdd("file2", "file2 content\nmore file2 content\n") shell.Commit("second commit") shell.UpdateFileAndAdd("file1", "file1 content\nmore file1 content\neven more file1\n") shell.Commit("third commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("third commit").IsSelected(), Contains("second commit"), Contains("first commit"), ). NavigateToLine(Contains("second commit")). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" M file1"), Equals(" M file2"), ). SelectNextItem(). // Add both files to the patch; the first will conflict, the second won't PressPrimaryAction(). Tap(func() { t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content( Contains("+more file1 content")) }). SelectNextItem(). PressPrimaryAction() t.Views().Secondary().Content( Contains("+more file1 content").Contains("+more file2 content")) t.Common().SelectPatchOption(Contains("Apply patch in reverse")) t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("Applied patch to 'file1' with conflicts.")). Confirm() t.Views().Files(). Focus(). Lines( Equals("UU file1").IsSelected(), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). ContainsLines( Contains("file1 content"), Contains("<<<<<<< ours").IsSelected(), Contains("more file1 content").IsSelected(), Contains("even more file1").IsSelected(), Contains("=======").IsSelected(), Contains(">>>>>>> theirs"), ). SelectNextItem(). PressPrimaryAction() t.Views().Files(). Focus(). Lines( Equals("▼ /").IsSelected(), Equals(" M file1"), Equals(" M file2"), ). SelectNextItem() t.Views().Main(). ContainsLines( Contains(" file1 content"), Contains("-more file1 content"), Contains("-even more file1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/edit_line_in_patch_building_panel.go000066400000000000000000000024361500612110400323430ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EditLineInPatchBuildingPanel = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Edit a line in the patch building panel; make sure we end up on the right line", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command" }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file.txt", "4\n5\n6\n") shell.Commit("01") shell.UpdateFileAndAdd("file.txt", "1\n2a\n2b\n3\n4\n5\n6\n") shell.Commit("02") shell.UpdateFile("file.txt", "1\n2\n3\n4\n5\n6\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("02").IsSelected(), Contains("01"), ). Press(keys.Universal.NextItem). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("A file.txt").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). Content(Contains("+4\n+5\n+6")). NavigateToLine(Contains("+5")). Press(keys.Universal.Edit) t.FileSystem().FileContent("edit-command", Contains("file.txt:5\n")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_range_to_index.go000066400000000000000000000035141500612110400275170ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveRangeToIndex = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Apply a custom patch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "first line\n") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "first line\nsecond line\n") shell.CreateFileAndAdd("file2", "file two content\n") shell.CreateFileAndAdd("file3", "file three content\n") shell.Commit("second commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("second commit").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" M file1"), Equals(" A file2"), Equals(" A file3"), ). SelectNextItem(). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file2")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content(Contains("second line")) t.Views().Secondary().Content(Contains("file two content")) t.Common().SelectPatchOption(MatchesRegexp(`Move patch out into index$`)) t.Views().CommitFiles(). IsFocused(). Lines( Contains("file3").IsSelected(), ).PressEscape() t.Views().Files(). Focus(). Lines( Equals("▼ /").IsSelected(), Equals(" M file1"), Equals(" A file2"), ) t.Views().Main(). Content(Contains("second line")) t.Views().Files().Focus().NavigateToLine(Contains("file2")) t.Views().Main(). Content(Contains("file two content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_earlier_commit.go000066400000000000000000000042571500612110400302340ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to an earlier commit", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.26.0"), SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "file1 content") shell.CreateFileAndAdd("dir/file2", "file2 content") shell.Commit("first commit") shell.CreateFileAndAdd("unrelated-file", "") shell.Commit("destination commit") shell.UpdateFileAndAdd("dir/file1", "file1 content with old changes") shell.DeleteFileAndAdd("dir/file2") shell.CreateFileAndAdd("dir/file3", "file3 content") shell.Commit("commit to move from") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit to move from").IsSelected(), Contains("destination commit"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("dir").IsSelected(), Contains(" M file1"), Contains(" D file2"), Contains(" A file3"), ). PressPrimaryAction(). PressEscape() t.Views().Information().Content(Contains("Building patch")) t.Views().Commits(). IsFocused(). SelectNextItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) t.Views().Commits(). IsFocused(). Lines( Contains("commit to move from"), Contains("destination commit").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir"), Equals(" M file1"), Equals(" D file2"), Equals(" A file3"), Equals(" A unrelated-file"), ). PressEscape() t.Views().Commits(). IsFocused(). SelectPreviousItem(). PressEnter() // the original commit has no more files in it t.Views().CommitFiles(). IsFocused(). Lines( Contains("(none)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_earlier_commit_from_added_file.go000066400000000000000000000051661500612110400333770ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToEarlierCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a file that was added in a commit to an earlier commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.EmptyCommit("destination commit") shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("commit to move from") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit to move from").IsSelected(), Contains("destination commit"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("A file").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Views().Commits(). Focus(). SelectNextItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) // This results in a conflict at the commit we're moving from, because // it tries to add a file that already exists t.Common().AcknowledgeConflicts() t.Views().Files(). IsFocused(). Lines( Contains("AA").Contains("file"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). TopLines( Contains("<<<<<<< HEAD"), Contains("2nd line"), Contains("======="), Contains("1st line"), Contains("2nd line"), Contains("3rd line"), Contains(">>>>>>>"), ). SelectNextItem(). PressPrimaryAction() // choose the version with all three lines t.Common().ContinueOnConflictsResolved("rebase") t.Views().Commits(). Focus(). Lines( Contains("commit to move from"), Contains("destination commit").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("A file").IsSelected(), ). Tap(func() { t.Views().Main().ContainsLines( Equals("+2nd line"), ) }). PressEscape() t.Views().Commits(). IsFocused(). NavigateToLine(Contains("commit to move from")). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("M file").IsSelected(), ). Tap(func() { t.Views().Main().ContainsLines( Equals("+1st line"), Equals(" 2nd line"), Equals("+3rd line"), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_earlier_commit_no_keep_empty.go000066400000000000000000000040211500612110400331370ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToEarlierCommitNoKeepEmpty = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to an earlier commit, for older git versions that don't keep the empty commit", ExtraCmdArgs: []string{}, Skip: false, GitVersion: Before("2.26.0"), SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "file1 content") shell.CreateFileAndAdd("dir/file2", "file2 content") shell.Commit("first commit") shell.CreateFileAndAdd("unrelated-file", "") shell.Commit("destination commit") shell.UpdateFileAndAdd("dir/file1", "file1 content with old changes") shell.DeleteFileAndAdd("dir/file2") shell.CreateFileAndAdd("dir/file3", "file3 content") shell.Commit("commit to move from") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit to move from").IsSelected(), Contains("destination commit"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("dir").IsSelected(), Contains(" M file1"), Contains(" D file2"), Contains(" A file3"), ). PressPrimaryAction(). PressEscape() t.Views().Information().Content(Contains("Building patch")) t.Views().Commits(). IsFocused(). SelectNextItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) t.Views().Commits(). IsFocused(). Lines( Contains("destination commit"), Contains("first commit").IsSelected(), ). SelectPreviousItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir"), Equals(" M file1"), Equals(" D file2"), Equals(" A file3"), Equals(" A unrelated-file"), ). PressEscape() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_index.go000066400000000000000000000027541500612110400263500ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToIndex = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to the index", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content\n") shell.CreateFileAndAdd("file2", "file2 content\n") shell.Commit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit").IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("file1"), Contains("file2"), ). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content(Contains("+file1 content")) t.Common().SelectPatchOption(Contains("Move patch out into index")) t.Views().Files(). Lines( Contains("A").Contains("file1"), ) t.Views().CommitFiles(). IsFocused(). Lines( Contains("file2").IsSelected(), ). PressEscape() t.Views().Main(). Content(Contains("+file2 content")) t.Views().Commits(). Lines( Contains("first commit").IsSelected(), ) t.Views().Files(). Focus() t.Views().Main(). Content(Contains("file1 content")) }, }) move_to_index_from_added_file_with_conflict.go000066400000000000000000000044171500612110400343460ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/patch_buildingpackage patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToIndexFromAddedFileWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a file that was added in a commit to the index, causing a conflict", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("commit to move from") shell.UpdateFileAndAdd("file1", "1st line\n2nd line changed\n3rd line\n") shell.Commit("conflicting change") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("conflicting change").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Common().SelectPatchOption(Contains("Move patch out into index")) t.Common().AcknowledgeConflicts() t.Views().Files(). IsFocused(). Lines( Contains("UU").Contains("file1"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). ContainsLines( Contains("1st line"), Contains("<<<<<<< HEAD"), Contains("======="), Contains("2nd line changed"), Contains(">>>>>>>"), Contains("3rd line"), ). SelectNextItem(). PressPrimaryAction() t.Common().ContinueOnConflictsResolved("rebase") t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("Applied patch to 'file1' with conflicts")). Confirm() t.Views().Files(). IsFocused(). Lines( Contains("UU").Contains("file1"), ). PressEnter() t.Views().MergeConflicts(). TopLines( Contains("1st line"), Contains("<<<<<<< ours"), Contains("2nd line changed"), Contains("======="), Contains("2nd line"), Contains(">>>>>>> theirs"), Contains("3rd line"), ). IsFocused() }, }) move_to_index_part_of_adjacent_added_lines.go000066400000000000000000000032331500612110400341400ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/patch_buildingpackage patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToIndexPartOfAdjacentAddedLines = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to the index, with only some lines of a range of adjacent added lines in the patch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "1st line\n2nd line\n") shell.Commit("commit to move from") shell.UpdateFileAndAdd("unrelated-file", "") shell.Commit("third commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("third commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Common().SelectPatchOption(Contains("Move patch out into index")) t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). Tap(func() { t.Views().Main(). Content(Contains("+2nd line"). DoesNotContain("1st line")) }) t.Views().Files(). Focus(). ContainsLines( Contains("M").Contains("file1"), ) t.Views().Main(). Content(Contains("+1st line\n 2nd line")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_index_partial.go000066400000000000000000000046331500612110400300620ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToIndexPartial = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to the index. This is different from the MoveToIndex test in that we're only selecting a partial patch from a file", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "first line\nsecond line\nthird line\n") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "first line2\nsecond line\nthird line2\n") shell.Commit("second commit") shell.CreateFileAndAdd("file2", "file1 content") shell.Commit("third commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("third commit").IsSelected(), Contains("second commit"), Contains("first commit"), ). NavigateToLine(Contains("second commit")). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). ContainsLines( Contains(`-first line`).IsSelected(), Contains(`+first line2`), Contains(` second line`), Contains(`-third line`), Contains(`+third line2`), ). PressPrimaryAction(). SelectNextItem(). PressPrimaryAction(). Tap(func() { t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary(). ContainsLines( Contains(`-first line`), Contains(`+first line2`), Contains(` second line`), Contains(` third line`), ) t.Common().SelectPatchOption(Contains("Move patch out into index")) t.Views().Files(). Lines( Contains("M").Contains("file1"), ) }) // Focus is automatically returned to the commit files panel. Arguably it shouldn't be. t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1"), ) t.Views().Main(). ContainsLines( Contains(` first line`), Contains(` second line`), Contains(`-third line`), Contains(`+third line2`), ) t.Views().Files(). Focus() t.Views().Main(). ContainsLines( Contains(`-first line`), Contains(`+first line2`), Contains(` second line`), Contains(` third line2`), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_index_with_conflict.go000066400000000000000000000042171500612110400312600ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToIndexWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to the index, causing a conflict", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "file1 content with old changes") shell.Commit("second commit") shell.UpdateFileAndAdd("file1", "file1 content with new changes") shell.Commit("third commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("third commit").IsSelected(), Contains("second commit"), Contains("first commit"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Common().SelectPatchOption(Contains("Move patch out into index")) t.Common().AcknowledgeConflicts() t.Views().Files(). IsFocused(). Lines( Contains("UU").Contains("file1"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). ContainsLines( Contains("<<<<<<< HEAD").IsSelected(), Contains("file1 content").IsSelected(), Contains("=======").IsSelected(), Contains("file1 content with new changes"), Contains(">>>>>>>"), ). PressPrimaryAction() t.Common().ContinueOnConflictsResolved("rebase") t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("Applied patch to 'file1' with conflicts")). Confirm() t.Views().Files(). IsFocused(). Lines( Contains("UU").Contains("file1"), ). PressEnter() t.Views().MergeConflicts(). TopLines( Contains("<<<<<<< ours"), Contains("file1 content"), Contains("======="), Contains("file1 content with old changes"), Contains(">>>>>>> theirs"), ). IsFocused() }, }) move_to_index_works_even_if_noprefix_is_set.go000066400000000000000000000023771500612110400344720ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/patch_buildingpackage patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToIndexWorksEvenIfNoprefixIsSet = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Moving a patch to the index works even if diff.noprefix or diff.external are set", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content\n") shell.Commit("first commit") // Test that this works even if custom diff options are set shell.SetConfig("diff.noprefix", "true") shell.SetConfig("diff.external", "echo") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit").IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressPrimaryAction() t.Views().Secondary().Content(Contains("+file1 content")) t.Common().SelectPatchOption(Contains("Move patch out into index")) t.Views().CommitFiles().IsFocused(). Lines( Equals("(none)"), ) t.Views().Files(). Lines( Contains("A").Contains("file1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_later_commit.go000066400000000000000000000042351500612110400277140ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToLaterCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to a later commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "file1 content") shell.CreateFileAndAdd("dir/file2", "file2 content") shell.Commit("first commit") shell.UpdateFileAndAdd("dir/file1", "file1 content with old changes") shell.DeleteFileAndAdd("dir/file2") shell.CreateFileAndAdd("dir/file3", "file3 content") shell.Commit("commit to move from") shell.CreateFileAndAdd("unrelated-file", "") shell.Commit("destination commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("destination commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("dir").IsSelected(), Contains(" M file1"), Contains(" D file2"), Contains(" A file3"), ). PressPrimaryAction(). PressEscape() t.Views().Information().Content(Contains("Building patch")) t.Views().Commits(). IsFocused(). SelectPreviousItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) t.Views().Commits(). IsFocused(). Lines( Contains("destination commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir"), Equals(" M file1"), Equals(" D file2"), Equals(" A file3"), Equals(" A unrelated-file"), ). PressEscape() t.Views().Commits(). IsFocused(). SelectNextItem(). PressEnter() // the original commit has no more files in it t.Views().CommitFiles(). IsFocused(). Lines( Contains("(none)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_later_commit_partial_hunk.go000066400000000000000000000042321500612110400324520ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToLaterCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to a later commit, with only parts of a hunk in the patch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "1st line\n2nd line\n") shell.Commit("commit to move from") shell.UpdateFileAndAdd("unrelated-file", "") shell.Commit("destination commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("destination commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). PressPrimaryAction(). PressEscape() t.Views().Information().Content(Contains("Building patch")) t.Views().CommitFiles(). IsFocused(). PressEscape() t.Views().Commits(). IsFocused(). SelectPreviousItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) t.Views().Commits(). IsFocused(). Lines( Contains("destination commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("file1"), Contains("unrelated-file"), ). SelectNextItem(). Tap(func() { t.Views().Main(). Content(Contains("+1st line\n 2nd line")) }). PressEscape() t.Views().Commits(). IsFocused(). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). Tap(func() { t.Views().Main(). Content(Contains("+2nd line"). DoesNotContain("1st line")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_new_commit.go000066400000000000000000000044751500612110400274040ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to a new commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "file1 content") shell.CreateFileAndAdd("dir/file2", "file2 content") shell.Commit("first commit") shell.UpdateFileAndAdd("dir/file1", "file1 content with old changes") shell.DeleteFileAndAdd("dir/file2") shell.CreateFileAndAdd("dir/file3", "file3 content") shell.Commit("commit to move from") shell.UpdateFileAndAdd("dir/file1", "file1 content with new changes") shell.Commit("third commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("third commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("dir").IsSelected(), Contains(" M file1"), Contains(" D file2"), Contains(" A file3"), ). PressPrimaryAction(). PressEscape() t.Views().Information().Content(Contains("Building patch")) t.Common().SelectPatchOption(Contains("Move patch into new commit")) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")). Type("new commit").Confirm() t.Views().Commits(). IsFocused(). Lines( Contains("third commit"), Contains("new commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("dir").IsSelected(), Contains(" M file1"), Contains(" D file2"), Contains(" A file3"), ). PressEscape() t.Views().Commits(). IsFocused(). Lines( Contains("third commit"), Contains("new commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). SelectNextItem(). PressEnter() // the original commit has no more files in it t.Views().CommitFiles(). IsFocused(). Lines( Contains("(none)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_new_commit_from_added_file.go000066400000000000000000000037221500612110400325410ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToNewCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a file that was added in a commit to a new commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("commit to move from") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit to move from").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Common().SelectPatchOption(Contains("Move patch into new commit")) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")). Type("new commit").Confirm() t.Views().Commits(). IsFocused(). Lines( Contains("new commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("M file1").IsSelected(), ). Tap(func() { t.Views().Main().ContainsLines( Equals(" 1st line"), Equals("+2nd line"), Equals(" 3rd line"), ) }). PressEscape() t.Views().Commits(). IsFocused(). NavigateToLine(Contains("commit to move from")). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("A file1").IsSelected(), ). Tap(func() { t.Views().Main().ContainsLines( Equals("+1st line"), Equals("+3rd line"), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_new_commit_from_deleted_file.go000066400000000000000000000040441500612110400331040ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToNewCommitFromDeletedFile = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a file that was deleted in a commit to a new commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("first commit") shell.DeleteFileAndAdd("file1") shell.Commit("commit to move from") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit to move from").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("D file1").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Common().SelectPatchOption(Contains("Move patch into new commit")) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")). Type("new commit").Confirm() t.Views().Commits(). IsFocused(). Lines( Contains("new commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("D file1").IsSelected(), ). Tap(func() { t.Views().Main().ContainsLines( Equals("-2nd line"), ) }). PressEscape() t.Views().Commits(). IsFocused(). NavigateToLine(Contains("commit to move from")). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( // In the original commit the file is no longer deleted, but modified Contains("M file1").IsSelected(), ). Tap(func() { t.Views().Main().ContainsLines( Equals("-1st line"), Equals(" 2nd line"), Equals("-3rd line"), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/move_to_new_commit_partial_hunk.go000066400000000000000000000043301500612110400321330ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var MoveToNewCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to a new commit, with only parts of a hunk in the patch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "") shell.Commit("first commit") shell.UpdateFileAndAdd("file1", "1st line\n2nd line\n") shell.Commit("commit to move from") shell.UpdateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("third commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("third commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Common().SelectPatchOption(Contains("Move patch into new commit")) t.ExpectPopup().CommitMessagePanel(). InitialText(Equals("")). Type("new commit").Confirm() t.Views().Commits(). IsFocused(). Lines( Contains("third commit"), Contains("new commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). Tap(func() { t.Views().Main(). Content(Contains("+1st line\n 2nd line")) }). PressEscape() t.Views().Commits(). IsFocused(). Lines( Contains("third commit"), Contains("new commit").IsSelected(), Contains("commit to move from"), Contains("first commit"), ). SelectNextItem(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). Tap(func() { t.Views().Main(). Content(Contains("+2nd line"). DoesNotContain("1st line")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/remove_from_commit.go000066400000000000000000000025561500612110400274010ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RemoveFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Remove a custom patch from a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content\n") shell.CreateFileAndAdd("file2", "file2 content\n") shell.Commit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit").IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("file1"), Contains("file2"), ). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content(Contains("+file1 content")) t.Common().SelectPatchOption(Contains("Remove patch from original commit")) t.Views().Files().IsEmpty() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file2").IsSelected(), ). PressEscape() t.Views().Main(). Content(Contains("+file2 content")) t.Views().Commits(). Lines( Contains("first commit").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/remove_parts_of_added_file.go000066400000000000000000000024771500612110400310250ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RemovePartsOfAddedFile = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Remove a custom patch from a file that was added in a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("commit to remove from") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit to remove from").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("A file1").IsSelected(), ). PressEnter() t.Views().PatchBuilding(). IsFocused(). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Common().SelectPatchOption(Contains("Remove patch from original commit")) t.Views().CommitFiles(). IsFocused(). Lines( Contains("A file1").IsSelected(), ). PressEscape() t.Views().Main().ContainsLines( Equals("+1st line"), Equals("+3rd line"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/reset_with_escape.go000066400000000000000000000020711500612110400271760ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ResetWithEscape = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reset a custom patch with the escape keybinding", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content") shell.Commit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit").IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressPrimaryAction(). Tap(func() { t.Views().Information().Content(Contains("Building patch")) }). PressEscape() // hitting escape at the top level will reset the patch t.Views().Commits(). IsFocused(). PressEscape() t.Views().Information().Content(DoesNotContain("Building patch")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/select_all_files.go000066400000000000000000000022061500612110400267720ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SelectAllFiles = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add all files of a commit to a custom patch with the 'a' keybinding", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content") shell.CreateFileAndAdd("file2", "file2 content") shell.CreateFileAndAdd("file3", "file3 content") shell.Commit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit").IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" A file1"), Equals(" A file2"), Equals(" A file3"), ). Press(keys.Files.ToggleStagedAll) t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content( Contains("file1").Contains("file3").Contains("file3"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/specific_selection.go000066400000000000000000000112001500612110400273250ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SpecificSelection = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Build a custom patch with a specific selection of lines, adding individual lines, as well as a range and hunk, and adding a file directly", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("hunk-file", "1a\n1b\n1c\n1d\n1e\n1f\n1g\n1h\n1i\n1j\n1k\n1l\n1m\n1n\n1o\n1p\n1q\n1r\n1s\n1t\n1u\n1v\n1w\n1x\n1y\n1z\n") shell.Commit("first commit") // making changes in two separate places for the sake of having two hunks shell.UpdateFileAndAdd("hunk-file", "aa\n1b\ncc\n1d\n1e\n1f\n1g\n1h\n1i\n1j\n1k\n1l\n1m\n1n\n1o\n1p\n1q\n1r\n1s\ntt\nuu\nvv\n1w\n1x\n1y\n1z\n") shell.CreateFileAndAdd("line-file", "2a\n2b\n2c\n2d\n2e\n2f\n2g\n2h\n2i\n2j\n2k\n2l\n2m\n2n\n2o\n2p\n2q\n2r\n2s\n2t\n2u\n2v\n2w\n2x\n2y\n2z\n") shell.CreateFileAndAdd("direct-file", "direct file content") shell.Commit("second commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("second commit").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("direct-file"), Contains("hunk-file"), Contains("line-file"), ). SelectNextItem(). PressPrimaryAction(). Tap(func() { t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content(Contains("direct file content")) }). NavigateToLine(Contains("hunk-file")). PressEnter() t.Views().PatchBuilding(). IsFocused(). SelectedLines( Contains("-1a"), ). Press(keys.Main.ToggleSelectHunk). SelectedLines( Contains(`@@ -1,6 +1,6 @@`), Contains(`-1a`), Contains(`+aa`), Contains(` 1b`), Contains(`-1c`), Contains(`+cc`), Contains(` 1d`), Contains(` 1e`), Contains(` 1f`), ). PressPrimaryAction(). // unlike in the staging panel, we don't remove lines from the patch building panel // upon 'adding' them. So the same lines will be selected SelectedLines( Contains(`@@ -1,6 +1,6 @@`), Contains(`-1a`), Contains(`+aa`), Contains(` 1b`), Contains(`-1c`), Contains(`+cc`), Contains(` 1d`), Contains(` 1e`), Contains(` 1f`), ). Tap(func() { t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content( // when we're inside the patch building panel, we only show the patch // in the secondary panel that relates to the selected file DoesNotContain("direct file content"). Contains("@@ -1,6 +1,6 @@"). Contains(" 1f"), ) }). // Cancel hunk select PressEscape(). // Escape the view PressEscape() t.Views().CommitFiles(). IsFocused(). NavigateToLine(Contains("line-file")). PressEnter() t.Views().PatchBuilding(). IsFocused(). SelectedLines( Contains("+2a"), ). PressPrimaryAction(). NavigateToLine(Contains("+2c")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("+2e")). PressPrimaryAction(). NavigateToLine(Contains("+2g")). PressPrimaryAction(). Tap(func() { t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().ContainsLines( Contains("+2a"), Contains("+2c"), Contains("+2d"), Contains("+2e"), Contains("+2g"), ) }). PressEscape(). Tap(func() { t.Views().Secondary().ContainsLines( // direct-file patch Contains(`diff --git a/direct-file b/direct-file`), Contains(`index`), Contains(`--- a/direct-file`), Contains(`+++ b/direct-file`), Contains(`@@ -0,0 +1 @@`), Contains(`+direct file content`), Contains(`\ No newline at end of file`), // hunk-file patch Contains(`diff --git a/hunk-file b/hunk-file`), Contains(`index`), Contains(`--- a/hunk-file`), Contains(`+++ b/hunk-file`), Contains(`@@ -1,6 +1,6 @@`), Contains(`-1a`), Contains(`+aa`), Contains(` 1b`), Contains(`-1c`), Contains(`+cc`), Contains(` 1d`), Contains(` 1e`), Contains(` 1f`), // line-file patch Contains(`diff --git a/line-file b/line-file`), Contains(`index`), Contains(`--- a/line-file`), Contains(`+++ b/line-file`), Contains(`@@ -0,0 +1,5 @@`), Contains(`+2a`), Contains(`+2c`), Contains(`+2d`), Contains(`+2e`), Contains(`+2g`), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/start_new_patch.go000066400000000000000000000031361500612110400266710ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StartNewPatch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Attempt to add a file from another commit to a patch, then agree to start a new patch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content") shell.Commit("first commit") shell.CreateFileAndAdd("file2", "file2 content") shell.Commit("second commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("second commit").IsSelected(), Contains("first commit"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file2").IsSelected(), ). PressPrimaryAction(). Tap(func() { t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content(Contains("file2")) }). PressEscape() t.Views().Commits(). IsFocused(). NavigateToLine(Contains("first commit")). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressPrimaryAction(). Tap(func() { t.ExpectPopup().Confirmation(). Title(Contains("Discard patch")). Content(Contains("You can only build a patch from one commit/stash-entry at a time. Discard current patch?")). Confirm() t.Views().Secondary().Content(Contains("file1").DoesNotContain("file2")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/patch_building/toggle_range.go000066400000000000000000000062271500612110400261450ustar00rootroot00000000000000package patch_building import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ToggleRange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Check multi select toggle logic", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateDir("dir1") shell.CreateFileAndAdd("dir1/file1-a", "d2f1 first line\nsecond line\nthird line\n") shell.CreateFileAndAdd("dir1/file2-a", "d1f2 first line\n") shell.CreateFileAndAdd("dir1/file3-a", "d1f3 first line\n") shell.CreateDir("dir2") shell.CreateFileAndAdd("dir2/file1-b", "d2f1 first line\nsecond line\nthird line\n") shell.CreateFileAndAdd("dir2/file2-b", "d2f2 first line\n") shell.CreateFileAndAdd("dir2/file3-b", "d2f3 first line\nsecond line\n") shell.Commit("first commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("first commit").IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ dir1"), Equals(" A file1-a"), Equals(" A file2-a"), Equals(" A file3-a"), Equals(" ▼ dir2"), Equals(" A file1-b"), Equals(" A file2-b"), Equals(" A file3-b"), ). NavigateToLine(Contains("file1-a")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file3-a")). PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ● file1-a").IsSelected(), Equals(" ● file2-a").IsSelected(), Equals(" ● file3-a").IsSelected(), Equals(" ▼ dir2"), Equals(" A file1-b"), Equals(" A file2-b"), Equals(" A file3-b"), ). PressEscape(). NavigateToLine(Contains("file3-b")). PressEnter() t.Views().Main().IsFocused(). NavigateToLine(Contains("second line")). PressPrimaryAction(). PressEscape() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /"), Equals(" ▼ dir1"), Equals(" ● file1-a"), Equals(" ● file2-a"), Equals(" ● file3-a"), Equals(" ▼ dir2"), Equals(" A file1-b"), Equals(" A file2-b"), Equals(" ◐ file3-b").IsSelected(), ). NavigateToLine(Contains("dir1")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("dir2")). PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" ▼ dir1").IsSelected(), Equals(" ● file1-a").IsSelected(), Equals(" ● file2-a").IsSelected(), Equals(" ● file3-a").IsSelected(), Equals(" ▼ dir2").IsSelected(), Equals(" ● file1-b"), Equals(" ● file2-b"), Equals(" ● file3-b"), ). PressPrimaryAction(). Lines( Equals("▼ /"), Equals(" ▼ dir1").IsSelected(), Equals(" A file1-a").IsSelected(), Equals(" A file2-a").IsSelected(), Equals(" A file3-a").IsSelected(), Equals(" ▼ dir2").IsSelected(), Equals(" A file1-b"), Equals(" A file2-b"), Equals(" A file3-b"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/reflog/000077500000000000000000000000001500612110400214545ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/reflog/checkout.go000066400000000000000000000025451500612110400236160ustar00rootroot00000000000000package reflog import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Checkout = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Checkout a reflog commit as a detached head", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.EmptyCommit("three") shell.HardReset("HEAD^^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().ReflogCommits(). Focus(). Lines( Contains("reset: moving to HEAD^^").IsSelected(), Contains("commit: three"), Contains("commit: two"), Contains("commit (initial): one"), ). SelectNextItem(). PressPrimaryAction(). Tap(func() { t.ExpectPopup().Menu(). Title(Contains("Checkout branch or commit")). Select(MatchesRegexp("Checkout commit [a-f0-9]+ as detached head")). Confirm() }). TopLines( Contains("checkout: moving from master to").IsSelected(), Contains("reset: moving to HEAD^^"), ) t.Views().Branches(). Lines( Contains("(HEAD detached at").IsSelected(), Contains("master"), ) t.Views().Commits(). Focus(). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/reflog/cherry_pick.go000066400000000000000000000024151500612110400243070ustar00rootroot00000000000000package reflog import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cherry pick a reflog commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.EmptyCommit("three") shell.HardReset("HEAD^^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().ReflogCommits(). Focus(). Lines( Contains("reset: moving to HEAD^^").IsSelected(), Contains("commit: three"), Contains("commit: two"), Contains("commit (initial): one"), ). SelectNextItem(). Press(keys.Commits.CherryPickCopy) t.Views().Information().Content(Contains("1 commit copied")) t.Views().Commits(). Focus(). Lines( Contains("one").IsSelected(), ). Press(keys.Commits.PasteCommits). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). Confirm() }). Lines( Contains("three").IsSelected(), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/reflog/do_not_show_branch_markers_in_reflog_subcommits.go000066400000000000000000000036251500612110400336650ustar00rootroot00000000000000package reflog import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DoNotShowBranchMarkersInReflogSubcommits = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that no branch heads are shown in the subcommits view of a reflog entry", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetAppState().GitLogShowGraph = "never" }, SetupRepo: func(shell *Shell) { shell.NewBranch("branch1") shell.EmptyCommit("one") shell.EmptyCommit("two") shell.NewBranch("branch2") shell.EmptyCommit("three") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // Check that the local commits view does show a branch marker for branch1 t.Views().Commits(). Lines( Contains("CI three"), Contains("CI * two"), Contains("CI one"), ) t.Views().Branches(). Focus(). // Check out branch1 NavigateToLine(Contains("branch1")). PressPrimaryAction(). // Look at the subcommits of branch2 NavigateToLine(Contains("branch2")). PressEnter(). // Check that we see a marker for branch1 here (but not for // branch2), even though branch1 is checked out Tap(func() { t.Views().SubCommits(). IsFocused(). Lines( Contains("CI three"), Contains("CI * two"), Contains("CI one"), ). PressEscape() }). // Check out branch2 again NavigateToLine(Contains("branch2")). PressPrimaryAction() t.Views().ReflogCommits(). Focus(). TopLines( Contains("checkout: moving from branch1 to branch2").IsSelected(), ). PressEnter(). // Check that the subcommits view for a reflog entry doesn't show // any branch markers Tap(func() { t.Views().SubCommits(). IsFocused(). Lines( Contains("CI three"), Contains("CI two"), Contains("CI one"), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/reflog/patch.go000066400000000000000000000032741500612110400231100ustar00rootroot00000000000000package reflog import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Patch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Build a patch from a reflog commit and apply it", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CreateFileAndAdd("file1", "content1") shell.CreateFileAndAdd("file2", "content2") shell.Commit("three") shell.HardReset("HEAD^^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().ReflogCommits(). Focus(). Lines( Contains("reset: moving to HEAD^^").IsSelected(), Contains("commit: three"), Contains("commit: two"), Contains("commit (initial): one"), ). SelectNextItem(). Lines( Contains("reset: moving to HEAD^^"), Contains("commit: three").IsSelected(), Contains("commit: two"), Contains("commit (initial): one"), ). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("file1"), Contains("file2"), ). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Views(). CommitFiles(). Press(keys.Universal.CreatePatchOptionsMenu) t.ExpectPopup().Menu(). Title(Equals("Patch options")). Select(MatchesRegexp(`Apply patch$`)).Confirm() t.Views().Files().Lines( Contains("file1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/reflog/reset.go000066400000000000000000000022571500612110400231330ustar00rootroot00000000000000package reflog import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Reset = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Hard reset to a reflog commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.EmptyCommit("three") shell.HardReset("HEAD^^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().ReflogCommits(). Focus(). Lines( Contains("reset: moving to HEAD^^").IsSelected(), Contains("commit: three"), Contains("commit: two"), Contains("commit (initial): one"), ). SelectNextItem(). Press(keys.Commits.ViewResetOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Contains("Reset to")). Select(Contains("Hard reset")). Confirm() }). TopLines( Contains("reset: moving to").IsSelected(), Contains("reset: moving to HEAD^^"), ) t.Views().Commits(). Focus(). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/shared/000077500000000000000000000000001500612110400214445ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/shared/README.md000066400000000000000000000001601500612110400227200ustar00rootroot00000000000000This package contains shared helper functions for tests. It is not intended to contain any actual tests itself. lazygit-0.50.0+ds1/pkg/integration/tests/shared/conflicts.go000066400000000000000000000066301500612110400237640ustar00rootroot00000000000000package shared import ( . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var OriginalFileContent = ` This Is The Original File ` var FirstChangeFileContent = ` This Is The First Change File ` var SecondChangeFileContent = ` This Is The Second Change File ` // prepares us for a rebase/merge that has conflicts var MergeConflictsSetup = func(shell *Shell) { shell. NewBranch("original-branch"). EmptyCommit("one"). EmptyCommit("two"). EmptyCommit("three"). CreateFileAndAdd("file", OriginalFileContent). Commit("original"). NewBranch("first-change-branch"). UpdateFileAndAdd("file", FirstChangeFileContent). Commit("first change"). Checkout("original-branch"). NewBranch("second-change-branch"). UpdateFileAndAdd("file", SecondChangeFileContent). Commit("second change"). EmptyCommit("second-change-branch unrelated change"). Checkout("first-change-branch") } var CreateMergeConflictFile = func(shell *Shell) { MergeConflictsSetup(shell) shell.RunCommandExpectError([]string{"git", "merge", "--no-edit", "second-change-branch"}) } var CreateMergeCommit = func(shell *Shell) { CreateMergeConflictFile(shell) shell.UpdateFileAndAdd("file", SecondChangeFileContent) shell.ContinueMerge() } // creates a merge conflict where there are two files with conflicts and a separate file without conflicts var CreateMergeConflictFiles = func(shell *Shell) { shell. NewBranch("original-branch"). EmptyCommit("one"). EmptyCommit("two"). EmptyCommit("three"). CreateFileAndAdd("file1", OriginalFileContent). CreateFileAndAdd("file2", OriginalFileContent). Commit("original"). NewBranch("first-change-branch"). UpdateFileAndAdd("file1", FirstChangeFileContent). UpdateFileAndAdd("file2", FirstChangeFileContent). Commit("first change"). Checkout("original-branch"). NewBranch("second-change-branch"). UpdateFileAndAdd("file1", SecondChangeFileContent). UpdateFileAndAdd("file2", SecondChangeFileContent). // this file is not changed in the second branch CreateFileAndAdd("file3", "content"). Commit("second change"). EmptyCommit("second-change-branch unrelated change"). Checkout("first-change-branch") shell.RunCommandExpectError([]string{"git", "merge", "--no-edit", "second-change-branch"}) } // These 'multiple' variants are just like the short ones but with longer file contents and with multiple conflicts within the file. var OriginalFileContentMultiple = ` This Is The Original File .. It Is Longer Than The Other Options ` var FirstChangeFileContentMultiple = ` This Is The First Change File .. It Is Longer Than The Other Other First Change ` var SecondChangeFileContentMultiple = ` This Is The Second Change File .. It Is Longer Than The Other Other Second Change ` var CreateMergeConflictFileMultiple = func(shell *Shell) { shell. NewBranch("original-branch"). EmptyCommit("one"). EmptyCommit("two"). EmptyCommit("three"). CreateFileAndAdd("file", OriginalFileContentMultiple). Commit("original"). NewBranch("first-change-branch"). UpdateFileAndAdd("file", FirstChangeFileContentMultiple). Commit("first change"). Checkout("original-branch"). NewBranch("second-change-branch"). UpdateFileAndAdd("file", SecondChangeFileContentMultiple). Commit("second change"). EmptyCommit("second-change-branch unrelated change"). Checkout("first-change-branch") shell.RunCommandExpectError([]string{"git", "merge", "--no-edit", "second-change-branch"}) } lazygit-0.50.0+ds1/pkg/integration/tests/shell_commands/000077500000000000000000000000001500612110400231665ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/shell_commands/basic_shell_command.go000066400000000000000000000015141500612110400274640ustar00rootroot00000000000000package shell_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var BasicShellCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using a custom command provided at runtime to create a new file", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsEmpty(). IsFocused(). Press(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). Type("touch file.txt"). Confirm() t.GlobalPress(keys.Files.RefreshFiles) t.Views().Files(). IsFocused(). Lines( Contains("file.txt"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/shell_commands/complex_shell_command.go000066400000000000000000000017731500612110400300610ustar00rootroot00000000000000package shell_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ComplexShellCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Using a custom command provided at runtime to create a new file, via a shell command. We invoke custom commands through a shell already. This test proves that we can run a shell within a shell, which requires complex escaping.", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsEmpty(). IsFocused(). Press(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). Type("sh -c \"touch file.txt\""). Confirm() t.GlobalPress(keys.Files.RefreshFiles) t.Views().Files(). IsFocused(). Lines( Contains("file.txt"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/shell_commands/delete_from_history.go000066400000000000000000000020711500612110400275630ustar00rootroot00000000000000package shell_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DeleteFromHistory = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Delete an entry from the custom commands history", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) {}, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { createCustomCommand := func(command string) { t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). Type(command). Confirm() } createCustomCommand("echo 1") createCustomCommand("echo 2") createCustomCommand("echo 3") t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). SuggestionLines( Contains("3"), Contains("2"), Contains("1"), ). DeleteSuggestion(Contains("2")). SuggestionLines( Contains("3"), Contains("1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/shell_commands/edit_history.go000066400000000000000000000015471500612110400262320ustar00rootroot00000000000000package shell_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EditHistory = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Edit an entry from the custom commands history", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) {}, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). Type("echo x"). Confirm() t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). Type("ec"). SuggestionLines( Equals("echo x"), ). EditSuggestion(Equals("echo x")). InitialText(Equals("echo x")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/shell_commands/history.go000066400000000000000000000030431500612110400252160ustar00rootroot00000000000000package shell_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var History = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Test that the custom commands history is saved correctly", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) {}, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). Type("echo 1"). Confirm() t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). SuggestionLines(Contains("1")). Type("echo 2"). Confirm() t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). SuggestionLines( // "echo 2" was typed last, so it should come first Contains("2"), Contains("1"), ). Type("echo 3"). Confirm() t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). SuggestionLines( Contains("3"), Contains("2"), Contains("1"), ). Type("echo 1"). Confirm() // Executing a command again should move it to the front: t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). SuggestionLines( Contains("1"), Contains("3"), Contains("2"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/shell_commands/omit_from_history.go000066400000000000000000000022021500612110400272650ustar00rootroot00000000000000package shell_commands import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var OmitFromHistory = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Omitting a runtime custom command from history if it begins with space", ExtraCmdArgs: []string{}, Skip: false, SetupRepo: func(shell *Shell) { shell.EmptyCommit("blah") }, SetupConfig: func(cfg *config.AppConfig) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). Type("echo aubergine"). Confirm() t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). SuggestionLines(Contains("aubergine")). SuggestionLines(DoesNotContain("tangerine")). Type(" echo tangerine"). Confirm() t.GlobalPress(keys.Universal.ExecuteShellCommand) t.ExpectPopup().Prompt(). Title(Equals("Shell command:")). SuggestionLines(Contains("aubergine")). SuggestionLines(DoesNotContain("tangerine")). Cancel() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/staging/000077500000000000000000000000001500612110400216325ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/staging/diff_change_screen_mode.go000066400000000000000000000022761500612110400267500ustar00rootroot00000000000000package staging import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiffChangeScreenMode = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Change the staged changes screen mode", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile("file", "first line\nsecond line") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Focus(). PressEnter() t.Views().Staging(). IsFocused(). PressPrimaryAction(). Title(Equals("Unstaged changes")). Content(Contains("+second line").DoesNotContain("+first line")). PressTab() t.Views().StagingSecondary(). IsFocused(). Title(Equals("Staged changes")). Content(Contains("+first line").DoesNotContain("+second line")). Press(keys.Universal.NextScreenMode). Tap(func() { t.Views().AppStatus(). IsInvisible() t.Views().Staging(). IsVisible() }). Press(keys.Universal.NextScreenMode). Tap(func() { t.Views().AppStatus(). IsInvisible() t.Views().Staging(). IsInvisible() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/staging/diff_context_change.go000066400000000000000000000075341500612110400261530ustar00rootroot00000000000000package staging import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiffContextChange = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Change the number of diff context lines while in the staging panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // need to be working with a few lines so that git perceives it as two separate hunks shell.CreateFileAndAdd("file1", "1a\n2a\n3a\n4a\n5a\n6a\n7a\n8a\n9a\n10a\n11a\n12a\n13a\n14a\n15a") shell.Commit("one") shell.UpdateFile("file1", "1a\n2a\n3b\n4a\n5a\n6a\n7a\n8a\n9a\n10a\n11a\n12a\n13b\n14a\n15a") // hunk looks like: // diff --git a/file1 b/file1 // index 3653080..a6388b6 100644 // --- a/file1 // +++ b/file1 // @@ -1,6 +1,6 @@ // 1a // 2a // -3a // +3b // 4a // 5a // 6a // @@ -10,6 +10,6 @@ // 10a // 11a // 12a // -13a // +13b // 14a // 15a // \ No newline at end of file }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().Staging(). IsFocused(). Press(keys.Main.ToggleSelectHunk). SelectedLines( Contains(`@@ -1,6 +1,6 @@`), Contains(` 1a`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), Contains(` 5a`), Contains(` 6a`), ). Press(keys.Universal.IncreaseContextInDiffView). Tap(func() { t.ExpectToast(Equals("Changed diff context size to 4")) }). SelectedLines( Contains(`@@ -1,7 +1,7 @@`), Contains(` 1a`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), Contains(` 5a`), Contains(` 6a`), Contains(` 7a`), ). Press(keys.Universal.DecreaseContextInDiffView). Tap(func() { t.ExpectToast(Equals("Changed diff context size to 3")) }). SelectedLines( Contains(`@@ -1,6 +1,6 @@`), Contains(` 1a`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), Contains(` 5a`), Contains(` 6a`), ). Press(keys.Universal.DecreaseContextInDiffView). Tap(func() { t.ExpectToast(Equals("Changed diff context size to 2")) }). SelectedLines( Contains(`@@ -1,5 +1,5 @@`), Contains(` 1a`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), Contains(` 5a`), ). Press(keys.Universal.DecreaseContextInDiffView). Tap(func() { t.ExpectToast(Equals("Changed diff context size to 1")) }). SelectedLines( Contains(`@@ -2,3 +2,3 @@`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), ). PressPrimaryAction(). Press(keys.Universal.TogglePanel) t.Views().StagingSecondary(). IsFocused(). Press(keys.Main.ToggleSelectHunk). SelectedLines( Contains(`@@ -2,3 +2,3 @@`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), ). Press(keys.Universal.DecreaseContextInDiffView). Tap(func() { t.ExpectToast(Equals("Changed diff context size to 0")) }). SelectedLines( Contains(`@@ -3,1 +3 @@`), Contains(`-3a`), Contains(`+3b`), ). Press(keys.Universal.IncreaseContextInDiffView). Tap(func() { t.ExpectToast(Equals("Changed diff context size to 1")) }). SelectedLines( Contains(`@@ -2,3 +2,3 @@`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), ). Press(keys.Universal.IncreaseContextInDiffView). Tap(func() { t.ExpectToast(Equals("Changed diff context size to 2")) }). SelectedLines( Contains(`@@ -1,5 +1,5 @@`), Contains(` 1a`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), Contains(` 5a`), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/staging/discard_all_changes.go000066400000000000000000000030341500612110400261120ustar00rootroot00000000000000package staging import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DiscardAllChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Discard all changes of a file in the staging panel, then assert we land in the staging panel of the next file", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "one\ntwo\n") shell.CreateFileAndAdd("file2", "1\n2\n") shell.Commit("one") shell.UpdateFile("file1", "one\ntwo\nthree\nfour\n") shell.UpdateFile("file2", "1\n2\n3\n4\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" M file1"), Equals(" M file2"), ). SelectNextItem(). PressEnter() t.Views().Staging(). IsFocused(). SelectedLines(Contains("+three")). // discard the line Press(keys.Universal.Remove). Tap(func() { t.Common().ConfirmDiscardLines() }). SelectedLines(Contains("+four")). // discard the other line Press(keys.Universal.Remove). Tap(func() { t.Common().ConfirmDiscardLines() // because there are no more changes in file1 we switch to file2 t.Views().Files(). Lines( Equals(" M file2"), ) }). // assert we are still in the staging panel, but now looking at the changes of the other file IsFocused(). SelectedLines(Contains("+3")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/staging/search.go000066400000000000000000000020561500612110400234310ustar00rootroot00000000000000package staging import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Search = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Use the search feature in the staging panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFile("file1", "one\ntwo\nthree\nfour\nfive") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().Staging(). IsFocused(). Press(keys.Universal.StartSearch). Tap(func() { t.ExpectSearch(). Type("four"). Confirm() t.Views().Search().IsVisible().Content(Contains("matches for 'four' (1 of 1)")) }). SelectedLine(Contains("+four")). // stage the line PressPrimaryAction(). Content(DoesNotContain("+four")). Tap(func() { t.Views().StagingSecondary(). Content(Contains("+four")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/staging/stage_hunks.go000066400000000000000000000067661500612110400245130ustar00rootroot00000000000000package staging import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StageHunks = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stage and unstage various hunks of a file in the staging panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // need to be working with a few lines so that git perceives it as two separate hunks shell.CreateFileAndAdd("file1", "1a\n2a\n3a\n4a\n5a\n6a\n7a\n8a\n9a\n10a\n11a\n12a\n13a\n14a\n15a") shell.Commit("one") shell.UpdateFile("file1", "1a\n2a\n3b\n4a\n5a\n6a\n7a\n8a\n9a\n10a\n11a\n12a\n13b\n14a\n15a") // hunk looks like: // diff --git a/file1 b/file1 // index 3653080..a6388b6 100644 // --- a/file1 // +++ b/file1 // @@ -1,6 +1,6 @@ // 1a // 2a // -3a // +3b // 4a // 5a // 6a // @@ -10,6 +10,6 @@ // 10a // 11a // 12a // -13a // +13b // 14a // 15a // \ No newline at end of file }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().Staging(). IsFocused(). SelectedLines( Contains("-3a"), ). Press(keys.Universal.NextBlock). SelectedLines( Contains("-13a"), ). Press(keys.Main.ToggleSelectHunk). SelectedLines( Contains("@@ -10,6 +10,6 @@"), Contains(" 10a"), Contains(" 11a"), Contains(" 12a"), Contains("-13a"), Contains("+13b"), Contains(" 14a"), Contains(" 15a"), Contains(`\ No newline at end of file`), ). // when in hunk mode, pressing up/down moves us up/down by a hunk SelectPreviousItem(). SelectedLines( Contains(`@@ -1,6 +1,6 @@`), Contains(` 1a`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), Contains(` 5a`), Contains(` 6a`), ). SelectNextItem(). SelectedLines( Contains("@@ -10,6 +10,6 @@"), Contains(" 10a"), Contains(" 11a"), Contains(" 12a"), Contains("-13a"), Contains("+13b"), Contains(" 14a"), Contains(" 15a"), Contains(`\ No newline at end of file`), ). // stage the second hunk PressPrimaryAction(). ContainsLines( Contains("-3a"), Contains("+3b"), ). Tap(func() { t.Views().StagingSecondary(). ContainsLines( Contains("-13a"), Contains("+13b"), ) }). Press(keys.Universal.TogglePanel) t.Views().StagingSecondary(). IsFocused(). // after toggling panel, we're back to only having selected a single line SelectedLines( Contains("-13a"), ). PressPrimaryAction(). SelectedLines( Contains("+13b"), ). PressPrimaryAction(). IsEmpty() t.Views().Staging(). IsFocused(). SelectedLines( Contains("-3a"), ). Press(keys.Main.ToggleSelectHunk). SelectedLines( Contains(`@@ -1,6 +1,6 @@`), Contains(` 1a`), Contains(` 2a`), Contains(`-3a`), Contains(`+3b`), Contains(` 4a`), Contains(` 5a`), Contains(` 6a`), ). Press(keys.Universal.Remove). Tap(func() { t.Common().ConfirmDiscardLines() }). Content(DoesNotContain("-3a").DoesNotContain("+3b")). SelectedLines( Contains("@@ -10,6 +10,6 @@"), Contains(" 10a"), Contains(" 11a"), Contains(" 12a"), Contains("-13a"), Contains("+13b"), Contains(" 14a"), Contains(" 15a"), Contains(`\ No newline at end of file`), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/staging/stage_lines.go000066400000000000000000000060011500612110400244530ustar00rootroot00000000000000package staging import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StageLines = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stage and unstage various lines of a file in the staging panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "one\ntwo\n") shell.Commit("one") shell.UpdateFile("file1", "one\ntwo\nthree\nfour\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().Staging(). IsFocused(). SelectedLines(Contains("+three")). // stage 'three' PressPrimaryAction(). // 'three' moves over to the staging secondary panel Content(DoesNotContain("+three")). Tap(func() { t.Views().StagingSecondary(). ContainsLines( Contains("+three"), ) }). SelectedLines(Contains("+four")). // stage 'four' PressPrimaryAction(). // nothing left in our staging panel IsEmpty() // because we've staged everything we get moved to the staging secondary panel // do the same thing as above, moving the lines back to the staging panel t.Views().StagingSecondary(). IsFocused(). ContainsLines( Contains("+three"), Contains("+four"), ). SelectedLines(Contains("+three")). PressPrimaryAction(). Content(DoesNotContain("+three")). Tap(func() { t.Views().Staging(). ContainsLines( Contains("+three"), ) }). SelectedLines(Contains("+four")). // pressing 'remove' has the same effect as pressing space when in the staging secondary panel Press(keys.Universal.Remove). IsEmpty() // stage one line and then manually toggle to the staging secondary panel t.Views().Staging(). IsFocused(). ContainsLines( Contains("+three"), Contains("+four"), ). SelectedLines(Contains("+three")). PressPrimaryAction(). Content(DoesNotContain("+three")). Tap(func() { t.Views().StagingSecondary(). Content(Contains("+three")) }). Press(keys.Universal.TogglePanel) // manually toggle back to the staging panel t.Views().StagingSecondary(). IsFocused(). Press(keys.Universal.TogglePanel) t.Views().Staging(). SelectedLines(Contains("+four")). // discard the line Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Discard change")). Content(Contains("Are you sure you want to discard this change")). Confirm() }). IsEmpty() t.Views().StagingSecondary(). IsFocused(). ContainsLines( Contains("+three"), ). // return to file PressEscape() t.Views().Files(). IsFocused(). Lines( Contains("M file1").IsSelected(), ). PressEnter() // because we only have a staged change we'll land in the staging secondary panel t.Views().StagingSecondary(). IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/staging/stage_ranges.go000066400000000000000000000046421500612110400246310ustar00rootroot00000000000000package staging import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StageRanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stage and unstage various ranges of a file in the staging panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "one\ntwo\n") shell.Commit("one") shell.UpdateFile("file1", "one\ntwo\nthree\nfour\nfive\nsix\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Lines( Contains("file1").IsSelected(), ). PressEnter() t.Views().Staging(). IsFocused(). SelectedLines( Contains("+three"), ). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("+five")). SelectedLines( Contains("+three"), Contains("+four"), Contains("+five"), ). // stage the three lines we've just selected PressPrimaryAction(). SelectedLines( Contains("+six"), ). ContainsLines( Contains(" five"), Contains("+six"), ). Tap(func() { t.Views().StagingSecondary(). ContainsLines( Contains("+three"), Contains("+four"), Contains("+five"), ) }). Press(keys.Universal.TogglePanel) t.Views().StagingSecondary(). IsFocused(). SelectedLines( Contains("+three"), ). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("+five")). SelectedLines( Contains("+three"), Contains("+four"), Contains("+five"), ). // unstage the three selected lines PressPrimaryAction(). // nothing left in our staging secondary panel IsEmpty(). Tap(func() { t.Views().Staging(). ContainsLines( Contains("+three"), Contains("+four"), Contains("+five"), Contains("+six"), ) }) t.Views().Staging(). IsFocused(). // coincidentally we land at '+four' here. Maybe we should instead land // at '+three'? given it's at the start of the hunk? SelectedLines( Contains("+four"), ). Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). SelectedLines( Contains("+four"), Contains("+five"), ). Press(keys.Universal.Remove). Tap(func() { t.Common().ConfirmDiscardLines() }). ContainsLines( Contains("+three"), Contains("+six"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/000077500000000000000000000000001500612110400213205ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/stash/apply.go000066400000000000000000000017701500612110400230010ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Apply = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Apply a stash entry", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("file", "content") shell.GitAddAll() shell.Stash("stash one") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsEmpty() t.Views().Stash(). Focus(). Lines( Contains("stash one").IsSelected(), ). PressPrimaryAction(). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Stash apply")). Content(Contains("Are you sure you want to apply this stash entry?")). Confirm() }). Lines( Contains("stash one").IsSelected(), ) t.Views().Files(). IsFocused(). Lines( Contains("file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/apply_patch.go000066400000000000000000000024631500612110400241600ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ApplyPatch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Restore part of a stash entry via applying a custom patch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("myfile", "content") shell.CreateFile("myfile2", "content") shell.GitAddAll() shell.Stash("stash one") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsEmpty() t.Views().Stash(). Focus(). Lines( Contains("stash one").IsSelected(), ). PressEnter(). Tap(func() { t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /").IsSelected(), Contains("myfile"), Contains("myfile2"), ). SelectNextItem(). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) t.Views(). CommitFiles(). Press(keys.Universal.CreatePatchOptionsMenu) t.ExpectPopup().Menu(). Title(Equals("Patch options")). Select(MatchesRegexp(`Apply patch$`)).Confirm() }) t.Views().Files().Lines( Contains("myfile"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/create_branch.go000066400000000000000000000024461500612110400244350ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CreateBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create a branch from a stash entry", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("myfile", "content") shell.GitAddAll() shell.Stash("stash one") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsEmpty() t.Views().Stash(). Focus(). Lines( Contains("stash one").IsSelected(), ). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().Prompt(). Title(Contains("New branch name (branch is off of 'stash@{0}: On master: stash one'")). Type("new_branch"). Confirm() }) t.Views().Files().IsEmpty() t.Views().Branches(). IsFocused(). Lines( Contains("new_branch").IsSelected(), Contains("master"), ). PressEnter() t.Views().SubCommits(). Lines( Contains("On master: stash one").IsSelected(), MatchesRegexp(`index on master:.*initial commit`), Contains("initial commit"), ) t.Views().Main().Content(Contains("myfile | 1 +")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/drop.go000066400000000000000000000016631500612110400226210ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Drop = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Drop a stash entry", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("file", "content") shell.GitAddAll() shell.Stash("stash one") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsEmpty() t.Views().Stash(). Focus(). Lines( Contains("stash one").IsSelected(), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Stash drop")). Content(Contains("Are you sure you want to drop the selected stash entry(ies)?")). Confirm() }). IsEmpty() t.Views().Files().IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/drop_multiple.go000066400000000000000000000026161500612110400245330ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DropMultiple = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Drop multiple stash entries", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFileAndAdd("file1", "content1") shell.Stash("stash one") shell.CreateFileAndAdd("file2", "content2") shell.Stash("stash two") shell.CreateFileAndAdd("file3", "content3") shell.Stash("stash three") shell.CreateFileAndAdd("file4", "content4") shell.Stash("stash four") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsEmpty() t.Views().Stash(). Focus(). SelectNextItem(). Lines( Contains("stash four"), Contains("stash three").IsSelected(), Contains("stash two"), Contains("stash one"), ). Press(keys.Universal.ToggleRangeSelect). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Stash drop")). Content(Contains("Are you sure you want to drop the selected stash entry(ies)?")). Confirm() }). Lines( Contains("stash four"), Contains("stash one"), ) t.Views().Files().IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/pop.go000066400000000000000000000017141500612110400224500ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Pop = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pop a stash entry", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("file", "content") shell.GitAddAll() shell.Stash("stash one") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsEmpty() t.Views().Stash(). Focus(). Lines( Contains("stash one").IsSelected(), ). Press(keys.Stash.PopStash). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Stash pop")). Content(Contains("Are you sure you want to pop this stash entry?")). Confirm() }). IsEmpty() t.Views().Files(). IsFocused(). Lines( Contains("file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/prevent_discarding_file_changes.go000066400000000000000000000020201500612110400302020ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PreventDiscardingFileChanges = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Check that it is not allowed to discard changes to a file of a stash", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("file", "content") shell.GitAddAll() shell.Stash("stash one") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsEmpty() t.Views().Stash(). Focus(). Lines( Contains("stash one").IsSelected(), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file").IsSelected(), ). Press(keys.Universal.Remove) t.ExpectPopup().Confirmation(). Title(Equals("Error")). Content(Contains("Changes can only be discarded from local commits")). Confirm() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/rename.go000066400000000000000000000017331500612110400231220ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Rename = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Try to rename the stash.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommit("blah"). CreateFileAndAdd("file-1", "change to stash1"). Stash("foo"). CreateFileAndAdd("file-2", "change to stash2"). Stash("bar") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Stash(). Focus(). Lines( Contains("On master: bar"), Contains("On master: foo"), ). SelectNextItem(). Press(keys.Stash.RenameStash). Tap(func() { t.ExpectPopup().Prompt().Title(Equals("Rename stash: stash@{1}")).Type(" baz").Confirm() }). SelectedLine(Contains("On master: foo baz")) t.Views().Main().Content(Contains("file-1")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/stash.go000066400000000000000000000016151500612110400227740ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Stash = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stashing files directly (not going through the stash menu)", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("file", "content") shell.GitAddAll() }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Stash(). IsEmpty() t.Views().Files(). Lines( Contains("file"), ). Press(keys.Files.StashAllChanges) t.ExpectPopup().Prompt().Title(Equals("Stash changes")).Type("my stashed file").Confirm() t.Views().Stash(). Lines( MatchesRegexp(`\ds .* my stashed file`), ) t.Views().Files(). IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/stash_all.go000066400000000000000000000017351500612110400236270ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StashAll = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stashing all changes (via the menu)", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("file", "content") shell.GitAddAll() }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Stash(). IsEmpty() t.Views().Files(). Lines( Contains("file"), ). Press(keys.Files.ViewStashOptions) t.ExpectPopup().Menu().Title(Equals("Stash options")).Select(MatchesRegexp("Stash all changes$")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Stash changes")).Type("my stashed file").Confirm() t.Views().Stash(). Lines( Contains("my stashed file"), ) t.Views().Files(). IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/stash_and_keep_index.go000066400000000000000000000026331500612110400260120ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StashAndKeepIndex = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stash staged changes", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file-staged", "content") shell.CreateFileAndAdd("file-unstaged", "content") shell.EmptyCommit("initial commit") shell.UpdateFileAndAdd("file-staged", "new content") shell.UpdateFile("file-unstaged", "new content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Stash(). IsEmpty() t.Views().Files(). Lines( Equals("▼ /"), Equals(" M file-staged"), Equals(" M file-unstaged"), ). Press(keys.Files.ViewStashOptions) t.ExpectPopup().Menu().Title(Equals("Stash options")).Select(Contains("Stash all changes and keep index")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Stash changes")).Type("my stashed file").Confirm() t.Views().Stash(). Lines( Contains("my stashed file"), ) t.Views().Files(). Lines( Equals("M file-staged"), ) t.Views().Stash(). Focus(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Equals("▼ /"), Equals(" M file-staged"), Equals(" M file-unstaged"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/stash_including_untracked_files.go000066400000000000000000000021611500612110400302470ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StashIncludingUntrackedFiles = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stashing all files including untracked ones", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFile("file_1", "content") shell.CreateFile("file_2", "content") shell.GitAdd("file_1") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Stash(). IsEmpty() t.Views().Files(). Lines( Equals("▼ /"), Equals(" A file_1"), Equals(" ?? file_2"), ). Press(keys.Files.ViewStashOptions) t.ExpectPopup().Menu().Title(Equals("Stash options")).Select(Contains("Stash all changes including untracked files")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Stash changes")).Type("my stashed file").Confirm() t.Views().Stash(). Lines( Contains("my stashed file"), ) t.Views().Files(). IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/stash_staged.go000066400000000000000000000025461500612110400243270ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StashStaged = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stash staged changes", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file-staged", "content") shell.CreateFileAndAdd("file-unstaged", "content") shell.EmptyCommit("initial commit") shell.UpdateFileAndAdd("file-staged", "new content") shell.UpdateFile("file-unstaged", "new content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Stash(). IsEmpty() t.Views().Files(). Lines( Equals("▼ /"), Equals(" M file-staged"), Equals(" M file-unstaged"), ). Press(keys.Files.ViewStashOptions) t.ExpectPopup().Menu().Title(Equals("Stash options")).Select(MatchesRegexp("Stash staged changes$")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Stash changes")).Type("my stashed file").Confirm() t.Views().Stash(). Lines( Contains("my stashed file"), ) t.Views().Files(). Lines( Equals(" M file-unstaged"), ) t.Views().Stash(). Focus(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file-staged").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/stash_staged_partial_file.go000066400000000000000000000033641500612110400270410ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StashStagedPartialFile = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stash staged changes when a file is partially staged", ExtraCmdArgs: []string{}, GitVersion: AtLeast("git version 2.35.0"), Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file-staged", "line1\nline2\nline3\nline4\n") shell.Commit("initial commit") shell.UpdateFile("file-staged", "line1\nline2 mod\nline3\nline4 mod\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). PressEnter() t.Views().Staging(). Content( Contains(" line1\n-line2\n+line2 mod\n line3\n-line4\n+line4 mod"), ). PressPrimaryAction(). PressPrimaryAction(). Content( Contains(" line1\n line2 mod\n line3\n-line4\n+line4 mod"), ). PressEscape() t.Views().Files(). IsFocused(). Press(keys.Files.ViewStashOptions) t.ExpectPopup().Menu().Title(Equals("Stash options")).Select(MatchesRegexp("Stash staged changes$")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Stash changes")).Type("my stashed file").Confirm() t.Views().Stash(). Focus(). Lines( Contains("my stashed file"), ). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file-staged").IsSelected(), ) t.Views().Main(). Content( Contains(" line1\n-line2\n+line2 mod\n line3\n line4"), ) t.Views().Files(). Focus(). Lines( Contains("file-staged"), ) t.Views().Main(). Content( Contains(" line1\n line2\n line3\n-line4\n+line4 mod"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/stash/stash_unstaged.go000066400000000000000000000025531500612110400246700ustar00rootroot00000000000000package stash import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var StashUnstaged = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Stash unstaged changes", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file-staged", "content") shell.CreateFileAndAdd("file-unstaged", "content") shell.EmptyCommit("initial commit") shell.UpdateFileAndAdd("file-staged", "new content") shell.UpdateFile("file-unstaged", "new content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Stash(). IsEmpty() t.Views().Files(). Lines( Equals("▼ /"), Equals(" M file-staged"), Equals(" M file-unstaged"), ). Press(keys.Files.ViewStashOptions) t.ExpectPopup().Menu().Title(Equals("Stash options")).Select(MatchesRegexp("Stash unstaged changes$")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Stash changes")).Type("my stashed file").Confirm() t.Views().Stash(). Lines( Contains("my stashed file"), ) t.Views().Files(). Lines( Contains("file-staged"), ) t.Views().Stash(). Focus(). PressEnter() t.Views().CommitFiles(). IsFocused(). Lines( Contains("file-unstaged").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/status/000077500000000000000000000000001500612110400215215ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/status/click_repo_name_to_open_repos_menu.go000066400000000000000000000011521500612110400311400ustar00rootroot00000000000000package status import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ClickRepoNameToOpenReposMenu = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Click on the repo name in the status side panel to open the recent repositories menu", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status().Click(1, 0) t.ExpectPopup().Menu().Title(Equals("Recent repositories")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/status/click_to_focus.go000066400000000000000000000020461500612110400250400ustar00rootroot00000000000000package status import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ClickToFocus = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Click in the status side panel to activate it", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().Focus() t.Views().Main().Lines( Contains("No changed files"), ) t.Views().Status().Click(0, 0) t.Views().Status().IsFocused() t.Views().Main().ContainsLines( Contains(` _`), Contains(` | | (_) |`), Contains(` | | __ _ _____ _ __ _ _| |_`), Contains(" | |/ _` |_ / | | |/ _` | | __|"), Contains(` | | (_| |/ /| |_| | (_| | | |_`), Contains(` |_|\__,_/___|\__, |\__, |_|\__|`), Contains(` __/ | __/ |`), Contains(` |___/ |___/`), Contains(``), Contains(`Copyright `), ) }, }) click_working_tree_state_to_open_rebase_options_menu.go000066400000000000000000000014021500612110400346750ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/statuspackage status import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ClickWorkingTreeStateToOpenRebaseOptionsMenu = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Click on the working tree state in the status side panel to open the rebase options menu", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateNCommits(2) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Press(keys.Universal.Edit) t.Views().Status(). Content(Contains("(rebasing) repo")). Click(1, 0) t.ExpectPopup().Menu().Title(Equals("Rebase options")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/status/log_cmd.go000066400000000000000000000020051500612110400234510ustar00rootroot00000000000000package status import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var LogCmd = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Cycle between two different log commands in the Status view", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.AllBranchesLogCmd = `echo "view1"` config.GetUserConfig().Git.AllBranchesLogCmds = []string{`echo "view2"`} }, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status(). Focus(). Press(keys.Status.AllBranchesLogGraph) t.Views().Main().Content(Contains("view1")) t.Views().Status(). Focus(). Press(keys.Status.AllBranchesLogGraph) t.Views().Main().Content(Contains("view2").DoesNotContain("view1")) t.Views().Status(). Focus(). Press(keys.Status.AllBranchesLogGraph) t.Views().Main().Content(Contains("view1").DoesNotContain("view2")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/status/show_divergence_from_base_branch.go000066400000000000000000000014311500612110400305540ustar00rootroot00000000000000package status import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ShowDivergenceFromBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Show divergence from base branch in the status panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.ShowDivergenceFromBaseBranch = "arrowAndNumber" }, SetupRepo: func(shell *Shell) { shell.CreateNCommits(2) shell.CloneIntoRemote("origin") shell.NewBranch("feature") shell.HardReset("HEAD^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.GlobalPress(keys.Universal.NextBlock) t.Views().Status(). Content(Equals("↓1 repo → feature")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/submodule/000077500000000000000000000000001500612110400221755ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/submodule/add.go000066400000000000000000000033251500612110400232570ustar00rootroot00000000000000package submodule import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Add = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add a submodule", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.Clone("other_repo") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Submodules().Focus(). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().Prompt(). Title(Equals("New submodule URL:")). Type("../other_repo").Confirm() t.ExpectPopup().Prompt(). Title(Equals("New submodule name:")). InitialText(Equals("other_repo")). Clear().Type("my_submodule").Confirm() t.ExpectPopup().Prompt(). Title(Equals("New submodule path:")). InitialText(Equals("my_submodule")). Clear().Type("my_submodule_path").Confirm() }). Lines( Contains("my_submodule").IsSelected(), ) t.Views().Main().TopLines( Contains("Name: my_submodule"), Contains("Path: my_submodule_path"), Contains("Url: ../other_repo"), ) t.Views().Files().Focus(). Lines( Equals("▼ /").IsSelected(), Equals(" A .gitmodules"), Equals(" A my_submodule_path (submodule)"), ). SelectNextItem(). Tap(func() { t.Views().Main().Content( Contains("[submodule \"my_submodule\"]"). Contains("path = my_submodule_path"). Contains("url = ../other_repo"), ) }). SelectNextItem(). Tap(func() { t.Views().Main().Content( Contains("Submodule my_submodule_path"). Contains("(new submodule)"), ) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/submodule/enter.go000066400000000000000000000043131500612110400236420ustar00rootroot00000000000000package submodule import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Enter = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Enter a submodule, add a commit, and then stage the change in the parent repo", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "e", Context: "files", Command: "git commit --allow-empty -m \"empty commit\"", }, } }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path") shell.GitAddAll() shell.Commit("add submodule") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { assertInParentRepo := func() { t.Views().Status().Content(Contains("repo")) } assertInSubmodule := func() { t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)")) } assertInParentRepo() t.Views().Submodules().Focus(). Lines( Contains("my_submodule_name").IsSelected(), ). // enter the submodule PressEnter() assertInSubmodule() t.Views().Files().IsFocused(). Press("e"). Tap(func() { t.Views().Commits().Content(Contains("empty commit")) }). // return to the parent repo PressEscape() assertInParentRepo() t.Views().Submodules().IsFocused() // we see the new commit in the submodule is ready to be staged in the parent repo t.Views().Main().Content(Contains("> empty commit")) t.Views().Files().Focus(). Lines( MatchesRegexp(` M.*my_submodule_path \(submodule\)`).IsSelected(), ). Tap(func() { // main view also shows the new commit when we're looking at the submodule within the files view t.Views().Main().Content(Contains("> empty commit")) }). PressPrimaryAction(). Press(keys.Files.CommitChanges). Tap(func() { t.ExpectPopup().CommitMessagePanel().Type("submodule change").Confirm() }). IsEmpty() t.Views().Submodules().Focus() // we no longer report a new commit because we've committed it in the parent repo t.Views().Main().Content(DoesNotContain("> empty commit")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/submodule/enter_nested.go000066400000000000000000000024371500612110400252110ustar00rootroot00000000000000package submodule import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EnterNested = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Enter a nested submodule", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) {}, SetupRepo: func(shell *Shell) { setupNestedSubmodules(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Submodules().Focus(). Lines( Equals("outerSubName").IsSelected(), Equals(" - innerSubName"), ). Tap(func() { t.Views().Main().ContainsLines( Contains("Name: outerSubName"), Contains("Path: modules/outerSubPath"), Contains("Url: ../outerSubmodule"), ) }). SelectNextItem(). Tap(func() { t.Views().Main().ContainsLines( Contains("Name: outerSubName/innerSubName"), Contains("Path: modules/outerSubPath/modules/innerSubPath"), Contains("Url: ../innerSubmodule"), ) }). // enter the nested submodule PressEnter() t.Views().Status().Content(Contains("innerSubPath(innerSubName)")) t.Views().Commits().ContainsLines( Contains("initial inner commit"), ) t.Views().Files().PressEscape() t.Views().Status().Content(Contains("repo")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/submodule/remove.go000066400000000000000000000027411500612110400240250ustar00rootroot00000000000000package submodule import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Remove = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Remove a submodule", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path") shell.GitAddAll() shell.Commit("add submodule") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { gitDirSubmodulePath := ".git/modules/my_submodule_name" t.FileSystem().PathPresent(gitDirSubmodulePath) t.Views().Submodules().Focus(). Lines( Contains("my_submodule_name").IsSelected(), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Remove submodule")). Content(Equals("Are you sure you want to remove submodule 'my_submodule_name' and its corresponding directory? This is irreversible.")). Confirm() }). IsEmpty() t.Views().Files().Focus(). Lines( Equals("▼ /").IsSelected(), Equals(" M .gitmodules"), Equals(" D my_submodule_path"), ). SelectNextItem() t.Views().Main().Content( Contains("-[submodule \"my_submodule_name\"]"). Contains("- path = my_submodule_path"). Contains("- url = ../my_submodule_name"), ) t.FileSystem().PathNotPresent(gitDirSubmodulePath) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/submodule/remove_nested.go000066400000000000000000000031251500612110400253640ustar00rootroot00000000000000package submodule import ( "path/filepath" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RemoveNested = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Remove a nested submodule", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { setupNestedSubmodules(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { gitDirSubmodulePath, _ := filepath.Abs(".git/modules/outerSubName/modules/innerSubName") t.FileSystem().PathPresent(gitDirSubmodulePath) t.Views().Submodules().Focus(). Lines( Equals("outerSubName").IsSelected(), Equals(" - innerSubName"), ). SelectNextItem(). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Remove submodule")). Content(Equals("Are you sure you want to remove submodule 'outerSubName/innerSubName' and its corresponding directory? This is irreversible.")). Confirm() }). Lines( Equals("outerSubName").IsSelected(), ). Press(keys.Universal.GoInto) t.Views().Files().IsFocused(). Lines( Equals("▼ /").IsSelected(), Equals(" ▼ modules"), Equals(" D innerSubPath"), Equals(" M .gitmodules"), ). NavigateToLine(Contains(".gitmodules")) t.Views().Main().Content( Contains("-[submodule \"innerSubName\"]"). Contains("- path = modules/innerSubPath"). Contains("- url = ../innerSubmodule"), ) t.FileSystem().PathNotPresent(gitDirSubmodulePath) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/submodule/reset.go000066400000000000000000000066661500612110400236640ustar00rootroot00000000000000package submodule import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Reset = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Enter a submodule, create a commit and stage some changes, then reset the submodule from back in the parent repo. This test captures functionality around getting a dirty submodule out of your files panel.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "e", Context: "files", Command: "git commit --allow-empty -m \"empty commit\" && echo \"my_file content\" > my_file", }, } }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path") shell.GitAddAll() shell.Commit("add submodule") shell.CreateFile("other_file", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { assertInParentRepo := func() { t.Views().Status().Content(Contains("repo")) } assertInSubmodule := func() { t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)")) } assertInParentRepo() t.Views().Submodules().Focus(). Lines( Contains("my_submodule_name").IsSelected(), ). // enter the submodule PressEnter() assertInSubmodule() t.Views().Files().IsFocused(). Press("e"). Tap(func() { t.Views().Commits().Content(Contains("empty commit")) t.Views().Files().Content(Contains("my_file")) }). Lines( Contains("my_file").IsSelected(), ). // stage my_file PressPrimaryAction(). // return to the parent repo PressEscape() assertInParentRepo() t.Views().Submodules().IsFocused() t.Views().Main().Content(Contains("Submodule my_submodule_path contains modified content")) t.Views().Files().Focus(). Lines( Equals("▼ /"), Equals(" M my_submodule_path (submodule)"), Equals(" ?? other_file").IsSelected(), ). // Verify we can't reset a submodule and file change at the same time. Press(keys.Universal.ToggleRangeSelect). SelectPreviousItem(). Lines( Equals("▼ /"), Equals(" M my_submodule_path (submodule)").IsSelected(), Equals(" ?? other_file").IsSelected(), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectToast(Contains("Disabled: Multiselection not supported for submodules")) }). Press(keys.Universal.ToggleRangeSelect). Lines( Equals("▼ /"), Equals(" M my_submodule_path (submodule)").IsSelected(), Equals(" ?? other_file"), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("my_submodule_path")). Select(Contains("Stash uncommitted submodule changes and update")). Confirm() }). Lines( Equals("?? other_file").IsSelected(), ) t.Views().Submodules().Focus(). PressEnter() assertInSubmodule() // submodule has been hard reset to the commit the parent repo specifies t.Views().Branches().Lines( Contains("HEAD detached"), Contains("master").IsSelected(), ) // empty commit is gone t.Views().Commits().Lines( Contains("first commit").IsSelected(), ) // the staged change has been stashed t.Views().Files().IsEmpty() t.Views().Stash().Focus(). Lines( Contains("WIP on master").IsSelected(), ) t.Views().Main().Content(Contains("my_file content")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/submodule/reset_folder.go000066400000000000000000000100371500612110400252020ustar00rootroot00000000000000package submodule import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ResetFolder = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Reset submodule changes located in a nested folder.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CreateDir("dir") shell.CloneIntoSubmodule("submodule1", "dir/submodule1") shell.CloneIntoSubmodule("submodule2", "dir/submodule2") shell.GitAddAll() shell.Commit("add submodules") shell.CreateFile("dir/submodule1/file", "") shell.CreateFile("dir/submodule2/file", "") shell.CreateFile("dir/file", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().Focus(). Lines( Equals("▼ dir").IsSelected(), Equals(" ?? file"), Equals(" M submodule1 (submodule)"), Equals(" M submodule2 (submodule)"), ). // Verify we cannot reset the entire folder (has nested file and submodule changes). Press(keys.Universal.Remove). Tap(func() { t.ExpectToast(Contains("Disabled: Multiselection not supported for submodules")) }). // Verify we cannot reset submodule + file or submodule + submodule via range select. SelectNextItem(). Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). Lines( Equals("▼ dir"), Equals(" ?? file").IsSelected(), Equals(" M submodule1 (submodule)").IsSelected(), Equals(" M submodule2 (submodule)"), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectToast(Contains("Disabled: Multiselection not supported for submodules")) }). Press(keys.Universal.ToggleRangeSelect). Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). Lines( Equals("▼ dir"), Equals(" ?? file"), Equals(" M submodule1 (submodule)").IsSelected(), Equals(" M submodule2 (submodule)").IsSelected(), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectToast(Contains("Disabled: Multiselection not supported for submodules")) }). // Reset the file change. Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("file")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() }). NavigateToLine(Contains("▼ dir")). Lines( Equals("▼ dir").IsSelected(), Equals(" M submodule1 (submodule)"), Equals(" M submodule2 (submodule)"), ). // Verify we still cannot reset the entire folder (has two submodule changes). Press(keys.Universal.Remove). Tap(func() { t.ExpectToast(Contains("Disabled: Multiselection not supported for submodules")) }). // Reset one of the submodule changes. SelectNextItem(). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("dir/submodule1")). Select(Contains("Stash uncommitted submodule changes and update")). Confirm() }). NavigateToLine(Contains("▼ dir")). Lines( Equals("▼ dir").IsSelected(), Equals(" M submodule2 (submodule)"), ). // Now we can reset the folder (equivalent to resetting just the nested submodule change). // Range selecting both the folder and submodule change is allowed. Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). Lines( Equals("▼ dir").IsSelected(), Equals(" M submodule2 (submodule)").IsSelected(), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("dir/submodule2")). Select(Contains("Stash uncommitted submodule changes and update")). Cancel() }). // Or just selecting the folder itself. NavigateToLine(Contains("▼ dir")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("dir/submodule2")). Select(Contains("Stash uncommitted submodule changes and update")). Confirm() }). IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/submodule/shared.go000066400000000000000000000026441500612110400240000ustar00rootroot00000000000000package submodule import ( . "github.com/jesseduffield/lazygit/pkg/integration/components" ) func setupNestedSubmodules(shell *Shell) { // we're going to have a directory structure like this: // project // - repo/modules/outerSubName/modules/innerSubName/ // shell.CreateFileAndAdd("rootFile", "rootStuff") shell.Commit("initial repo commit") shell.Chdir("..") shell.CreateDir("innerSubmodule") shell.Chdir("innerSubmodule") shell.Init() shell.CreateFileAndAdd("inner", "inner") shell.Commit("initial inner commit") shell.Chdir("..") shell.CreateDir("outerSubmodule") shell.Chdir("outerSubmodule") shell.Init() shell.CreateFileAndAdd("outer", "outer") shell.Commit("initial outer commit") shell.CreateDir("modules") // the git config (-c) parameter below is required // to let git create a file-protocol/path submodule shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "add", "--name", "innerSubName", "../innerSubmodule", "modules/innerSubPath"}) shell.Commit("add dependency as innerSubmodule") shell.Chdir("../repo") shell.CreateDir("modules") shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "add", "--name", "outerSubName", "../outerSubmodule", "modules/outerSubPath"}) shell.Commit("add dependency as outerSubmodule") shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "update", "--init", "--recursive"}) } lazygit-0.50.0+ds1/pkg/integration/tests/sync/000077500000000000000000000000001500612110400211525ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches.go000066400000000000000000000031501500612110400325310ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FetchAndAutoForwardBranchesAllBranches = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Fetch from remote and auto-forward branches with config set to 'allBranches'", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.AutoForwardBranches = "allBranches" }, SetupRepo: func(shell *Shell) { shell.CreateNCommits(3) shell.NewBranch("feature") shell.NewBranch("diverged") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.SetBranchUpstream("feature", "origin/feature") shell.SetBranchUpstream("diverged", "origin/diverged") shell.Checkout("master") shell.HardReset("HEAD^") shell.Checkout("feature") shell.HardReset("HEAD~2") shell.Checkout("diverged") shell.HardReset("HEAD~2") shell.EmptyCommit("local") shell.NewBranch("checked-out") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("checked-out").IsSelected(), Contains("diverged ↓2↑1"), Contains("feature ↓2").DoesNotContain("↑"), Contains("master ↓1").DoesNotContain("↑"), ) t.Views().Files(). IsFocused(). Press(keys.Files.Fetch) // AutoForwardBranches is "allBranches": both master and feature get forwarded t.Views().Branches(). Lines( Contains("checked-out").IsSelected(), Contains("diverged ↓2↑1"), Contains("feature ✓"), Contains("master ✓"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/fetch_and_auto_forward_branches_none.go000066400000000000000000000031521500612110400310550ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FetchAndAutoForwardBranchesNone = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Fetch from remote and auto-forward branches with config set to 'none'", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.AutoForwardBranches = "none" }, SetupRepo: func(shell *Shell) { shell.CreateNCommits(3) shell.NewBranch("feature") shell.NewBranch("diverged") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.SetBranchUpstream("feature", "origin/feature") shell.SetBranchUpstream("diverged", "origin/diverged") shell.Checkout("master") shell.HardReset("HEAD^") shell.Checkout("feature") shell.HardReset("HEAD~2") shell.Checkout("diverged") shell.HardReset("HEAD~2") shell.EmptyCommit("local") shell.NewBranch("checked-out") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("checked-out").IsSelected(), Contains("diverged ↓2↑1"), Contains("feature ↓2").DoesNotContain("↑"), Contains("master ↓1").DoesNotContain("↑"), ) t.Views().Files(). IsFocused(). Press(keys.Files.Fetch) // AutoForwardBranches is "none": nothing should happen t.Views().Branches(). Lines( Contains("checked-out").IsSelected(), Contains("diverged ↓2↑1"), Contains("feature ↓2").DoesNotContain("↑"), Contains("master ↓1").DoesNotContain("↑"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/fetch_and_auto_forward_branches_only_main_branches.go000066400000000000000000000032301500612110400337450ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FetchAndAutoForwardBranchesOnlyMainBranches = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Fetch from remote and auto-forward branches with config set to 'onlyMainBranches'", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.AutoForwardBranches = "onlyMainBranches" }, SetupRepo: func(shell *Shell) { shell.CreateNCommits(3) shell.NewBranch("feature") shell.NewBranch("diverged") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.SetBranchUpstream("feature", "origin/feature") shell.SetBranchUpstream("diverged", "origin/diverged") shell.Checkout("master") shell.HardReset("HEAD^") shell.Checkout("feature") shell.HardReset("HEAD~2") shell.Checkout("diverged") shell.HardReset("HEAD~2") shell.EmptyCommit("local") shell.NewBranch("checked-out") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("checked-out").IsSelected(), Contains("diverged ↓2↑1"), Contains("feature ↓2").DoesNotContain("↑"), Contains("master ↓1").DoesNotContain("↑"), ) t.Views().Files(). IsFocused(). Press(keys.Files.Fetch) // AutoForwardBranches is "onlyMainBranches": master gets forwarded, but feature doesn't t.Views().Branches(). Lines( Contains("checked-out").IsSelected(), Contains("diverged ↓2↑1"), Contains("feature ↓2").DoesNotContain("↑"), Contains("master ✓"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/fetch_prune.go000066400000000000000000000026371500612110400240130ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FetchPrune = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Fetch from the remote with the 'prune' option set in the git config", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // This option makes it so that git checks for deleted branches in the remote // upon fetching. shell.SetConfig("fetch.prune", "true") shell.EmptyCommit("my commit message") shell.NewBranch("branch_to_remove") shell.Checkout("master") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.SetBranchUpstream("branch_to_remove", "origin/branch_to_remove") // # unbeknownst to our test repo we're removing the branch on the remote, so upon // # fetching with prune: true we expect git to realise the remote branch is gone shell.RemoveRemoteBranch("origin", "branch_to_remove") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("master"), Contains("branch_to_remove").DoesNotContain("upstream gone"), ) t.Views().Files(). IsFocused(). Press(keys.Files.Fetch) t.Views().Branches(). Lines( Contains("master"), Contains("branch_to_remove").Contains("upstream gone"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/fetch_when_sorted_by_date.go000066400000000000000000000032621500612110400266650ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FetchWhenSortedByDate = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Fetch a branch while sort order is by date; verify that branch stays selected", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell. EmptyCommitWithDate("commit", "2023-04-07 10:00:00"). // first master commit, older than branch2 EmptyCommitWithDate("commit", "2023-04-07 12:00:00"). // second master commit, newer than branch2 NewBranch("branch1"). // branch1 will be checked out, so its date doesn't matter EmptyCommitWithDate("commit", "2023-04-07 11:00:00"). // branch2 commit, date is between the two master commits NewBranch("branch2"). Checkout("master"). CloneIntoRemote("origin"). SetBranchUpstream("master", "origin/master"). // upstream points to second master commit HardReset("HEAD^"). // rewind to first master commit Checkout("branch1") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Press(keys.Branches.SortOrder) t.ExpectPopup().Menu().Title(Equals("Sort order")). Select(Contains("-committerdate")). Confirm() t.Views().Branches(). Lines( Contains("* branch1").IsSelected(), Contains("branch2"), Contains("master ↓1"), ). NavigateToLine(Contains("master")). Press(keys.Branches.FetchRemote). Lines( Contains("* branch1"), Contains("master").IsSelected(), Contains("branch2"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/force_push.go000066400000000000000000000026621500612110400236440ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ForcePush = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push to a remote with new commits, requiring a force push", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") // remove the 'two' commit so that we have something to pull from the remote shell.HardReset("HEAD^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Equals("↓1 repo → master")) t.Views().Files().IsFocused().Press(keys.Universal.Push) t.ExpectPopup().Confirmation(). Title(Equals("Force push")). Content(Equals("Your branch has diverged from the remote branch. Press to cancel, or to force push.")). Confirm() t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Remotes().Focus(). Lines(Contains("origin")). PressEnter() t.Views().RemoteBranches().IsFocused(). Lines(Contains("master")). PressEnter() t.Views().SubCommits().IsFocused(). Lines(Contains("one")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/force_push_multiple_matching.go000066400000000000000000000024331500612110400274250ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ForcePushMultipleMatching = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Force push to multiple branches because the user has push.default matching", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.SetConfig("push.default", "matching") createTwoBranchesReadyToForcePush(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Equals("↓1 repo → master")) t.Views().Branches(). Lines( Contains("master ↓1"), Contains("other_branch ↓1"), ) t.Views().Files().IsFocused().Press(keys.Universal.Push) t.ExpectPopup().Confirmation(). Title(Equals("Force push")). Content(Equals("Your branch has diverged from the remote branch. Press to cancel, or to force push.")). Confirm() t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Branches(). Lines( Contains("master ✓"), Contains("other_branch ✓"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/force_push_multiple_upstream.go000066400000000000000000000024701500612110400274740ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ForcePushMultipleUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Force push to only the upstream branch of the current branch because the user has push.default upstream", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.SetConfig("push.default", "upstream") createTwoBranchesReadyToForcePush(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Equals("↓1 repo → master")) t.Views().Branches(). Lines( Contains("master ↓1"), Contains("other_branch ↓1"), ) t.Views().Files().IsFocused().Press(keys.Universal.Push) t.ExpectPopup().Confirmation(). Title(Equals("Force push")). Content(Equals("Your branch has diverged from the remote branch. Press to cancel, or to force push.")). Confirm() t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Branches(). Lines( Contains("master ✓"), Contains("other_branch ↓1"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/force_push_remote_branch_not_stored_locally.go000066400000000000000000000045501500612110400325110ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ForcePushRemoteBranchNotStoredLocally = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push a branch whose remote branch is not stored locally, requiring a force push", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.Clone("some-remote") // remove the 'two' commit so that we have something to pull from the remote shell.HardReset("HEAD^") shell.SetConfig("branch.master.remote", "../some-remote") shell.SetConfig("branch.master.pushRemote", "../some-remote") shell.SetConfig("branch.master.merge", "refs/heads/master") shell.CreateFileAndAdd("file1", "file1 content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Contains("? repo → master")) // We're behind our upstream now, so we expect to be asked to force-push t.Views().Files().IsFocused().Press(keys.Universal.Push) t.ExpectPopup().Confirmation(). Title(Equals("Force push")). Content(Equals("Your branch has diverged from the remote branch. Press to cancel, or to force push.")). Confirm() // Make a new local commit t.Views().Files().IsFocused().Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel().Type("new").Confirm() t.Views().Commits(). Lines( Contains("new"), Contains("one"), ) // Pushing this works without needing to force push t.Views().Files().IsFocused().Press(keys.Universal.Push) // Now add the clone as a remote just so that we can check if what we // pushed arrived there correctly t.Views().Remotes().Focus(). Press(keys.Universal.New) t.ExpectPopup().Prompt(). Title(Equals("New remote name:")).Type("some-remote").Confirm() t.ExpectPopup().Prompt(). Title(Equals("New remote url:")).Type("../some-remote").Confirm() t.Views().Remotes().Lines( Contains("some-remote").IsSelected(), ). PressEnter() t.Views().RemoteBranches().IsFocused().Lines( Contains("master").IsSelected(), ). PressEnter() t.Views().SubCommits().IsFocused().Lines( Contains("new"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/force_push_triangular.go000066400000000000000000000032471500612110400260740ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ForcePushTriangular = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push to a remote, requiring a force push because the branch is behind the remote push branch but not the upstream", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.22.0"), SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.SetConfig("push.default", "current") shell.EmptyCommit("one") shell.CloneIntoRemote("origin") shell.NewBranch("feature") shell.SetBranchUpstream("feature", "origin/master") shell.EmptyCommit("two") shell.PushBranch("origin", "feature") // remove the 'two' commit so that we are behind the push branch shell.HardReset("HEAD^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Contains("✓ repo → feature")) t.Views().Files().IsFocused().Press(keys.Universal.Push) t.ExpectPopup().Confirmation(). Title(Equals("Force push")). Content(Equals("Your branch has diverged from the remote branch. Press to cancel, or to force push.")). Confirm() t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Contains("✓ repo → feature")) t.Views().Remotes().Focus(). Lines(Contains("origin")). PressEnter() t.Views().RemoteBranches().IsFocused(). Lines( Contains("feature"), Contains("master"), ). PressEnter() t.Views().SubCommits().IsFocused(). Lines(Contains("one")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/pull.go000066400000000000000000000017651500612110400224660ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Pull = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pull a commit from the remote", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") // remove the 'two' commit so that we have something to pull from the remote shell.HardReset("HEAD^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Equals("↓1 repo → master")) t.Views().Files().IsFocused().Press(keys.Universal.Pull) t.Views().Commits(). Lines( Contains("two"), Contains("one"), ) t.Views().Status().Content(Equals("✓ repo → master")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/pull_and_set_upstream.go000066400000000000000000000022321500612110400260710ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PullAndSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pull a commit from the remote, setting the upstream branch in the process", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CloneIntoRemote("origin") // remove the 'two' commit so that we have something to pull from the remote shell.HardReset("HEAD^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("one"), ) t.Views().Status().Content(Equals("repo → master")) t.Views().Files().IsFocused().Press(keys.Universal.Pull) t.ExpectPopup().Prompt(). Title(Equals("Enter upstream as ' '")). SuggestionLines(Equals("origin master")). ConfirmFirstSuggestion() t.Views().Commits(). Lines( Contains("two"), Contains("one"), ) t.Views().Status().Content(Equals("✓ repo → master")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/pull_merge.go000066400000000000000000000023421500612110400236350ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pull with a merge strategy", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "content1") shell.Commit("one") shell.UpdateFileAndAdd("file", "content2") shell.Commit("two") shell.EmptyCommit("three") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.HardReset("HEAD^^") shell.EmptyCommit("four") shell.SetConfig("pull.rebase", "false") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("four"), Contains("one"), ) t.Views().Status().Content(Equals("↓2↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Pull) t.Views().Status().Content(Equals("↑2 repo → master")) t.Views().Commits(). Lines( Contains("Merge branch 'master' of ../origin"), Contains("three"), Contains("two"), Contains("four"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/pull_merge_conflict.go000066400000000000000000000035661500612110400255270ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PullMergeConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pull with a merge strategy, where a conflict occurs", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "content1") shell.Commit("one") shell.UpdateFileAndAdd("file", "content2") shell.Commit("two") shell.EmptyCommit("three") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.HardReset("HEAD^^") shell.UpdateFileAndAdd("file", "content4") shell.Commit("four") shell.SetConfig("pull.rebase", "false") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("four"), Contains("one"), ) t.Views().Status().Content(Equals("↓2↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Pull) t.Common().AcknowledgeConflicts() t.Views().Files(). IsFocused(). Lines( Contains("UU").Contains("file"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). TopLines( Contains("<<<<<<< HEAD"), Contains("content4"), Contains("======="), Contains("content2"), Contains(">>>>>>>"), ). PressPrimaryAction() // choose 'content4' t.Common().ContinueOnConflictsResolved("merge") t.Views().Status().Content(Equals("↑2 repo → master")) t.Views().Commits(). Focus(). Lines( Contains("Merge branch 'master' of ../origin").IsSelected(), Contains("three"), Contains("two"), Contains("four"), Contains("one"), ) t.Views().Main(). Content( Contains("- content4"). Contains(" -content2"). Contains("++content4"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/pull_rebase.go000066400000000000000000000024011500612110400237730ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PullRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pull with a rebase strategy", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "content1") shell.Commit("one") shell.UpdateFileAndAdd("file", "content2") shell.Commit("two") shell.CreateFileAndAdd("file3", "content3") shell.Commit("three") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.HardReset("HEAD^^") shell.CreateFileAndAdd("file4", "content4") shell.Commit("four") shell.SetConfig("pull.rebase", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("four"), Contains("one"), ) t.Views().Status().Content(Equals("↓2↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Pull) t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Commits(). Lines( Contains("four"), Contains("three"), Contains("two"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/pull_rebase_conflict.go000066400000000000000000000034721500612110400256650ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PullRebaseConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pull with a rebase strategy, where a conflict occurs", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "content1") shell.Commit("one") shell.UpdateFileAndAdd("file", "content2") shell.Commit("two") shell.EmptyCommit("three") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.HardReset("HEAD^^") shell.UpdateFileAndAdd("file", "content4") shell.Commit("four") shell.SetConfig("pull.rebase", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("four"), Contains("one"), ) t.Views().Status().Content(Equals("↓2↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Pull) t.Common().AcknowledgeConflicts() t.Views().Files(). IsFocused(). Lines( Contains("UU").Contains("file"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). TopLines( Contains("<<<<<<< HEAD"), Contains("content2"), Contains("======="), Contains("content4"), Contains(">>>>>>>"), ). SelectNextItem(). PressPrimaryAction() // choose 'content4' t.Common().ContinueOnConflictsResolved("rebase") t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Commits(). Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ) t.Views().Main(). Content( Contains("-content2"). Contains("+content4"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go000066400000000000000000000044421500612110400302600ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pull with an interactive rebase strategy, where a conflict occurs", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "content1") shell.Commit("one") shell.UpdateFileAndAdd("file", "content2") shell.Commit("two") shell.CreateFileAndAdd("file3", "content3") shell.Commit("three") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.HardReset("HEAD^^") shell.UpdateFileAndAdd("file", "content4") shell.Commit("four") shell.CreateFileAndAdd("file5", "content5") shell.Commit("five") shell.SetConfig("pull.rebase", "interactive") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("five"), Contains("four"), Contains("one"), ) t.Views().Status().Content(Equals("↓2↑2 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Pull) t.Common().AcknowledgeConflicts() t.Views().Commits(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("five"), Contains("pick").Contains("CONFLICT").Contains("four"), Contains("--- Commits ---"), Contains("three"), Contains("two"), Contains("one"), ) t.Views().Files(). IsFocused(). Lines( Contains("UU").Contains("file"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). TopLines( Contains("<<<<<<< HEAD"), Contains("content2"), Contains("======="), Contains("content4"), Contains(">>>>>>>"), ). SelectNextItem(). PressPrimaryAction() // choose 'content4' t.Common().ContinueOnConflictsResolved("rebase") t.Views().Status().Content(Equals("↑2 repo → master")) t.Views().Commits(). Focus(). Lines( Contains("five").IsSelected(), Contains("four"), Contains("three"), Contains("two"), Contains("one"), ). SelectNextItem() t.Views().Main(). Content( Contains("-content2"). Contains("+content4"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go000066400000000000000000000051431500612110400313030ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Pull with an interactive rebase strategy, where a conflict occurs. Also drop a commit while rebasing", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file", "content1") shell.Commit("one") shell.UpdateFileAndAdd("file", "content2") shell.Commit("two") shell.CreateFileAndAdd("file3", "content3") shell.Commit("three") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.HardReset("HEAD^^") shell.UpdateFileAndAdd("file", "content4") shell.Commit("four") shell.CreateFileAndAdd("fil5", "content5") shell.Commit("five") shell.SetConfig("pull.rebase", "interactive") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("five"), Contains("four"), Contains("one"), ) t.Views().Status().Content(Equals("↓2↑2 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Pull) t.Common().AcknowledgeConflicts() t.Views().Commits(). Focus(). Lines( Contains("--- Pending rebase todos ---"), Contains("pick").Contains("five").IsSelected(), Contains("pick").Contains("CONFLICT").Contains("four"), Contains("--- Commits ---"), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.Remove). Lines( Contains("--- Pending rebase todos ---"), Contains("drop").Contains("five").IsSelected(), Contains("pick").Contains("CONFLICT").Contains("four"), Contains("--- Commits ---"), Contains("three"), Contains("two"), Contains("one"), ) t.Views().Files(). Focus(). Lines( Contains("UU").Contains("file"), ). PressEnter() t.Views().MergeConflicts(). IsFocused(). TopLines( Contains("<<<<<<< HEAD"), Contains("content2"), Contains("======="), Contains("content4"), Contains(">>>>>>>"), ). SelectNextItem(). PressPrimaryAction() // choose 'content4' t.Common().ContinueOnConflictsResolved("rebase") t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Commits(). Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ) t.Views().Main(). Content( Contains("-content2"). Contains("+content4"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/push.go000066400000000000000000000013671500612110400224670ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Push = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push a commit to a pre-configured upstream", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.EmptyCommit("two") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Push) assertSuccessfullyPushed(t) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/push_and_auto_set_upstream.go000066400000000000000000000015151500612110400271270ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PushAndAutoSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push a commit and set the upstream automatically as configured by git", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneIntoRemote("origin") shell.EmptyCommit("two") shell.SetConfig("push.default", "current") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // assert no mention of upstream/downstream changes t.Views().Status().Content(Equals("repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Push) assertSuccessfullyPushed(t) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/push_and_set_upstream.go000066400000000000000000000016511500612110400261000ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PushAndSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push a commit and set the upstream via a prompt", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneIntoRemote("origin") shell.EmptyCommit("two") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // assert no mention of upstream/downstream changes t.Views().Status().Content(Equals("repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Push) t.ExpectPopup().Prompt(). Title(Equals("Enter upstream as ' '")). SuggestionLines(Equals("origin master")). ConfirmFirstSuggestion() assertSuccessfullyPushed(t) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/push_follow_tags.go000066400000000000000000000023031500612110400250560ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PushFollowTags = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push with --follow-tags configured in git config", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.EmptyCommit("two") shell.CreateAnnotatedTag("mytag", "message", "HEAD") shell.SetConfig("push.followTags", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Push) t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Remotes(). Focus(). Lines( Contains("origin"), ). PressEnter() t.Views().RemoteBranches(). IsFocused(). Lines( Contains("master"), ). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("two").Contains("mytag"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/push_no_follow_tags.go000066400000000000000000000024061500612110400255560ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PushNoFollowTags = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push with --follow-tags NOT configured in git config", ExtraCmdArgs: []string{}, Skip: true, // turns out this actually DOES push the tag. I have no idea why SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.CreateAnnotatedTag("mytag", "message", "HEAD") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Push) t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Remotes(). Focus(). Lines( Contains("origin"), ). PressEnter() t.Views().RemoteBranches(). IsFocused(). Lines( Contains("master"), ). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( // tag was not pushed to upstream Contains("two").DoesNotContain("mytag"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/push_tag.go000066400000000000000000000022261500612110400233150ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PushTag = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push a specific tag", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CloneIntoRemote("origin") shell.CreateAnnotatedTag("mytag", "message", "HEAD") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Tags(). Focus(). Lines( Contains("mytag"), ). Press(keys.Branches.PushTag) t.ExpectPopup().Prompt(). Title(Equals("Remote to push tag 'mytag' to:")). InitialText(Equals("origin")). SuggestionLines( Contains("origin"), ). Confirm() t.Views().Remotes(). Focus(). Lines( Contains("origin"), ). PressEnter() t.Views().RemoteBranches(). IsFocused(). Lines( Contains("master"), ). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("two").Contains("mytag"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/push_with_credential_prompt.go000066400000000000000000000036761500612110400273220ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var PushWithCredentialPrompt = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Push a commit to a pre-configured upstream, where credentials are required", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.EmptyCommit("two") // actually getting a password prompt is tricky: it requires SSH'ing into localhost under a newly created, restricted, user. // This is not easy to do in a cross-platform way, nor is it easy to do in a docker container. // If you can think of a way to do it, please let me know! shell.CopyHelpFile("pre-push", ".git/hooks/pre-push") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Push) // correct credentials are: username=username, password=password t.ExpectPopup().Prompt(). Title(Equals("Username")). Type("username"). Confirm() // enter incorrect password t.ExpectPopup().Prompt(). Title(Equals("Password")). Type("incorrect password"). Confirm() t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Contains("incorrect username/password")). Confirm() t.Views().Status().Content(Equals("↑1 repo → master")) // try again with correct password t.Views().Files(). IsFocused(). Press(keys.Universal.Push) t.ExpectPopup().Prompt(). Title(Equals("Username")). Type("username"). Confirm() t.ExpectPopup().Prompt(). Title(Equals("Password")). Type("password"). Confirm() t.Views().Status().Content(Equals("✓ repo → master")) assertSuccessfullyPushed(t) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/rename_branch_and_pull.go000066400000000000000000000026561500612110400261540ustar00rootroot00000000000000package sync import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RenameBranchAndPull = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Rename a branch to no longer match its upstream, then pull from the upstream", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") // remove the 'two' commit so that we have something to pull from the remote shell.HardReset("HEAD^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( Contains("one"), ) t.Views().Branches(). Focus(). Lines( Contains("master"), ). Press(keys.Branches.RenameBranch). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Rename branch")). Content(Equals("This branch is tracking a remote. This action will only rename the local branch name, not the name of the remote branch. Continue?")). Confirm() t.ExpectPopup().Prompt(). Title(Contains("Enter new branch name")). InitialText(Equals("master")). Type("-local"). Confirm() }). Press(keys.Universal.Pull) t.Views().Commits(). Lines( Contains("two"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/sync/shared.go000066400000000000000000000016771500612110400227620ustar00rootroot00000000000000package sync import ( . "github.com/jesseduffield/lazygit/pkg/integration/components" ) func createTwoBranchesReadyToForcePush(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.NewBranch("other_branch") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("master", "origin/master") shell.SetBranchUpstream("other_branch", "origin/other_branch") // remove the 'two' commit so that we have something to pull from the remote shell.HardReset("HEAD^") shell.Checkout("master") // doing the same for master shell.HardReset("HEAD^") } func assertSuccessfullyPushed(t *TestDriver) { t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Remotes(). Focus(). Lines( Contains("origin"), ). PressEnter() t.Views().RemoteBranches(). IsFocused(). Lines( Contains("master"), ). PressEnter() t.Views().SubCommits(). IsFocused(). Lines( Contains("two"), Contains("one"), ) } lazygit-0.50.0+ds1/pkg/integration/tests/tag/000077500000000000000000000000001500612110400207515ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/tag/checkout.go000066400000000000000000000014021500612110400231020ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Checkout = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Checkout a tag", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CreateLightweightTag("tag", "HEAD^") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Tags(). Focus(). Lines( Contains("tag").IsSelected(), ). PressPrimaryAction() // checkout tag t.Views().Branches().IsFocused().Lines( Contains("HEAD detached at tag").IsSelected(), Contains("master"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/checkout_when_branch_with_same_name_exists.go000066400000000000000000000016161500612110400321060ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CheckoutWhenBranchWithSameNameExists = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Checkout a tag when there's a branch with the same name", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.NewBranch("tag") shell.Checkout("master") shell.EmptyCommit("two") shell.CreateLightweightTag("tag", "HEAD") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Tags(). Focus(). Lines( Contains("tag").IsSelected(), ). PressPrimaryAction() // checkout tag t.Views().Branches().IsFocused().Lines( Contains("HEAD detached at tag").IsSelected(), Contains("master"), Contains("tag"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/copy_to_clipboard.go000066400000000000000000000015611500612110400247760ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CopyToClipboard = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Copy the tag to the clipboard", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.CreateLightweightTag("super.l000ongtag", "HEAD") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Tags(). Focus(). Lines( Contains("tag").IsSelected(), ). Press(keys.Universal.CopyToClipboard) t.ExpectToast(Equals("'super.l000ongtag' copied to clipboard")) t.FileSystem().FileContent("clipboard", Equals("super.l000ongtag")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/create_while_committing.go000066400000000000000000000020171500612110400261650ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CreateWhileCommitting = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Draft a commit message, escape out, and make a tag. Verify the draft message doesn't appear in the tag create prompt", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CreateFileAndAdd("file.txt", "file contents") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Press(keys.Files.CommitChanges). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). Type("draft message"). Cancel() }) t.Views().Tags(). Focus(). IsEmpty(). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). InitialText(Equals("")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/crud_annotated.go000066400000000000000000000046021500612110400242740ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CrudAnnotated = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create and delete an annotated tag in the tags panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CloneIntoRemote("origin") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Tags(). Focus(). IsEmpty(). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). Type("new-tag"). SwitchToDescription(). Title(Equals("Tag description")). Type("message"). SwitchToSummary(). Confirm() }). Lines( MatchesRegexp(`new-tag.*message`).IsSelected(), ). Press(keys.Universal.Push). Tap(func() { t.ExpectPopup().Prompt(). Title(Equals("Remote to push tag 'new-tag' to:")). InitialText(Equals("origin")). SuggestionLines( Contains("origin"), ). Confirm() }). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete tag 'new-tag'?")). Select(Contains("Delete remote tag")). Confirm() }). Tap(func() { t.ExpectPopup().Prompt(). Title(Equals("Remote from which to remove tag 'new-tag':")). InitialText(Equals("origin")). SuggestionLines( Contains("origin"), ). Confirm() }). Tap(func() { t.ExpectPopup(). Confirmation(). Title(Equals("Delete tag 'new-tag'?")). Content(Equals("Are you sure you want to delete the remote tag 'new-tag' from 'origin'?")). Confirm() t.ExpectToast(Equals("Remote tag deleted")) }). Lines( MatchesRegexp(`new-tag.*message`).IsSelected(), ). Tap(func() { t.Git(). RemoteTagDeleted("origin", "new-tag") }). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete tag 'new-tag'?")). Select(Contains("Delete local tag")). Confirm() }). IsEmpty(). Press(keys.Universal.New). Tap(func() { // confirm content is cleared on next tag create t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). InitialText(Equals("")) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/crud_lightweight.go000066400000000000000000000044151500612110400246400ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CrudLightweight = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create and delete a lightweight tag in the tags panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CloneIntoRemote("origin") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Tags(). Focus(). IsEmpty(). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). Type("new-tag"). Confirm() }). Lines( MatchesRegexp(`new-tag.*initial commit`).IsSelected(), ). PressEnter(). Tap(func() { // view the commits of the tag t.Views().SubCommits().IsFocused(). Lines( Contains("initial commit"), ). PressEscape() }). Press(keys.Universal.Push). Tap(func() { t.ExpectPopup().Prompt(). Title(Equals("Remote to push tag 'new-tag' to:")). InitialText(Equals("origin")). SuggestionLines( Contains("origin"), ). Confirm() }). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete tag 'new-tag'?")). Select(Contains("Delete remote tag")). Confirm() }). Tap(func() { t.ExpectPopup().Prompt(). Title(Equals("Remote from which to remove tag 'new-tag':")). InitialText(Equals("origin")). SuggestionLines( Contains("origin"), ). Confirm() }). Tap(func() { t.ExpectPopup(). Confirmation(). Title(Equals("Delete tag 'new-tag'?")). Content(Equals("Are you sure you want to delete the remote tag 'new-tag' from 'origin'?")). Confirm() t.ExpectToast(Equals("Remote tag deleted")) }). Lines( MatchesRegexp(`new-tag.*initial commit`).IsSelected(), ). Tap(func() { t.Git(). RemoteTagDeleted("origin", "new-tag") }). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete tag 'new-tag'?")). Select(Contains("Delete local tag")). Confirm() }). IsEmpty() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/delete_local_and_remote.go000066400000000000000000000036731500612110400261220ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DeleteLocalAndRemote = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Create and delete both local and remote annotated tag", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("initial commit") shell.CloneIntoRemote("origin") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Tags(). Focus(). IsEmpty(). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). Type("new-tag"). SwitchToDescription(). Title(Equals("Tag description")). Type("message"). SwitchToSummary(). Confirm() }). Lines( MatchesRegexp(`new-tag.*message`).IsSelected(), ). Press(keys.Universal.Push). Tap(func() { t.ExpectPopup().Prompt(). Title(Equals("Remote to push tag 'new-tag' to:")). InitialText(Equals("origin")). SuggestionLines( Contains("origin"), ). Confirm() }). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete tag 'new-tag'?")). Select(Contains("Delete local and remote tag")). Confirm() }). Tap(func() { t.ExpectPopup().Prompt(). Title(Equals("Remote from which to remove tag 'new-tag':")). InitialText(Equals("origin")). SuggestionLines( Contains("origin"), ). Confirm() }). Tap(func() { t.ExpectPopup(). Confirmation(). Title(Equals("Delete tag 'new-tag'?")). Content(Equals("Are you sure you want to delete 'new-tag' from both your machine and from 'origin'?")). Confirm() }). IsEmpty(). Press(keys.Universal.New). Tap(func() { t.Shell().AssertRemoteTagNotFound("origin", "new-tag") }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/force_tag_annotated.go000066400000000000000000000025241500612110400252710ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ForceTagAnnotated = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Overwrite an annotated tag that already exists", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CreateAnnotatedTag("new-tag", "message", "HEAD") shell.EmptyCommit("second commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("second commit").IsSelected(), Contains("new-tag").Contains("first commit"), ). Press(keys.Commits.CreateTag). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). Type("new-tag"). SwitchToDescription(). Title(Equals("Tag description")). Type("message"). SwitchToSummary(). Confirm() }). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Force Tag")). Content(Contains("The tag 'new-tag' exists already. Press to cancel, or to overwrite.")). Confirm() }). Lines( Contains("new-tag").Contains("second commit"), DoesNotContain("new-tag").Contains("first commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/force_tag_lightweight.go000066400000000000000000000023351500612110400256330ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ForceTagLightweight = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Overwrite a lightweight tag that already exists", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.CreateLightweightTag("new-tag", "HEAD") shell.EmptyCommit("second commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("second commit").IsSelected(), Contains("new-tag").Contains("first commit"), ). Press(keys.Commits.CreateTag). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Tag name")). Type("new-tag"). Confirm() }). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Force Tag")). Content(Contains("The tag 'new-tag' exists already. Press to cancel, or to overwrite.")). Confirm() }). Lines( Contains("new-tag").Contains("second commit"), DoesNotContain("new-tag").Contains("first commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/tag/reset.go000066400000000000000000000016331500612110400224250ustar00rootroot00000000000000package tag import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Reset = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Hard reset to a tag", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.CreateLightweightTag("tag", "HEAD^") // creating tag on commit "one" }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Lines( Contains("two"), Contains("one"), ) t.Views().Tags(). Focus(). Lines( Contains("tag").IsSelected(), ). Press(keys.Commits.ViewResetOptions) t.ExpectPopup().Menu(). Title(Contains("Reset to tag")). Select(Contains("Hard reset")). Confirm() t.Views().Commits().Lines( Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/test_list.go000066400000000000000000000346301500612110400225450ustar00rootroot00000000000000// THIS FILE IS AUTO-GENERATED. You can regenerate it by running `go generate ./...` at the root of the lazygit repo. package tests import ( "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/bisect" "github.com/jesseduffield/lazygit/pkg/integration/tests/branch" "github.com/jesseduffield/lazygit/pkg/integration/tests/cherry_pick" "github.com/jesseduffield/lazygit/pkg/integration/tests/commit" "github.com/jesseduffield/lazygit/pkg/integration/tests/config" "github.com/jesseduffield/lazygit/pkg/integration/tests/conflicts" "github.com/jesseduffield/lazygit/pkg/integration/tests/custom_commands" "github.com/jesseduffield/lazygit/pkg/integration/tests/demo" "github.com/jesseduffield/lazygit/pkg/integration/tests/diff" "github.com/jesseduffield/lazygit/pkg/integration/tests/file" "github.com/jesseduffield/lazygit/pkg/integration/tests/filter_and_search" "github.com/jesseduffield/lazygit/pkg/integration/tests/filter_by_author" "github.com/jesseduffield/lazygit/pkg/integration/tests/filter_by_path" "github.com/jesseduffield/lazygit/pkg/integration/tests/interactive_rebase" "github.com/jesseduffield/lazygit/pkg/integration/tests/misc" "github.com/jesseduffield/lazygit/pkg/integration/tests/patch_building" "github.com/jesseduffield/lazygit/pkg/integration/tests/reflog" "github.com/jesseduffield/lazygit/pkg/integration/tests/shell_commands" "github.com/jesseduffield/lazygit/pkg/integration/tests/staging" "github.com/jesseduffield/lazygit/pkg/integration/tests/stash" "github.com/jesseduffield/lazygit/pkg/integration/tests/status" "github.com/jesseduffield/lazygit/pkg/integration/tests/submodule" "github.com/jesseduffield/lazygit/pkg/integration/tests/sync" "github.com/jesseduffield/lazygit/pkg/integration/tests/tag" "github.com/jesseduffield/lazygit/pkg/integration/tests/ui" "github.com/jesseduffield/lazygit/pkg/integration/tests/undo" "github.com/jesseduffield/lazygit/pkg/integration/tests/worktree" ) var tests = []*components.IntegrationTest{ bisect.Basic, bisect.ChooseTerms, bisect.FromOtherBranch, bisect.Skip, branch.CheckoutAutostash, branch.CheckoutByName, branch.CreateTag, branch.Delete, branch.DeleteMultiple, branch.DeleteRemoteBranchWithCredentialPrompt, branch.DeleteRemoteBranchWithDifferentName, branch.DeleteWhileFiltering, branch.DetachedHead, branch.MoveCommitsToNewBranchFromBaseBranch, branch.MoveCommitsToNewBranchFromMainBranch, branch.MoveCommitsToNewBranchKeepStacked, branch.NewBranchAutostash, branch.NewBranchFromRemoteTrackingDifferentName, branch.NewBranchFromRemoteTrackingSameName, branch.NewBranchWithPrefix, branch.NewBranchWithPrefixUsingRunCommand, branch.OpenPullRequestInvalidTargetRemoteName, branch.OpenPullRequestNoUpstream, branch.OpenPullRequestSelectRemoteAndTargetBranch, branch.OpenWithCliArg, branch.Rebase, branch.RebaseAbortOnConflict, branch.RebaseAndDrop, branch.RebaseCancelOnConflict, branch.RebaseConflictsFixBuildErrors, branch.RebaseCopiedBranch, branch.RebaseDoesNotAutosquash, branch.RebaseFromMarkedBase, branch.RebaseOntoBaseBranch, branch.RebaseToUpstream, branch.Rename, branch.Reset, branch.ResetToUpstream, branch.SelectCommitsOfCurrentBranch, branch.SetUpstream, branch.ShowDivergenceFromBaseBranch, branch.ShowDivergenceFromUpstream, branch.ShowDivergenceFromUpstreamNoDivergence, branch.SortLocalBranches, branch.SortRemoteBranches, branch.SquashMerge, branch.Suggestions, branch.UnsetUpstream, cherry_pick.CherryPick, cherry_pick.CherryPickConflicts, cherry_pick.CherryPickDuringRebase, cherry_pick.CherryPickMerge, cherry_pick.CherryPickRange, commit.AddCoAuthor, commit.AddCoAuthorRange, commit.AddCoAuthorWhileCommitting, commit.Amend, commit.AmendWhenThereAreConflictsAndAmend, commit.AmendWhenThereAreConflictsAndCancel, commit.AmendWhenThereAreConflictsAndContinue, commit.AutoWrapMessage, commit.Checkout, commit.CheckoutFileFromCommit, commit.CheckoutFileFromRangeSelectionOfCommits, commit.Commit, commit.CommitMultiline, commit.CommitSkipHooks, commit.CommitSwitchToEditor, commit.CommitSwitchToEditorSkipHooks, commit.CommitWipWithPrefix, commit.CommitWithFallthroughPrefix, commit.CommitWithGlobalPrefix, commit.CommitWithNonMatchingBranchName, commit.CommitWithPrefix, commit.CopyAuthorToClipboard, commit.CopyMessageBodyToClipboard, commit.CopyTagToClipboard, commit.CreateAmendCommit, commit.CreateFixupCommitInBranchStack, commit.CreateTag, commit.DisableCopyCommitMessageBody, commit.DiscardOldFileChanges, commit.FailHooksThenCommitNoHooks, commit.FindBaseCommitForFixup, commit.FindBaseCommitForFixupDisregardMainBranch, commit.FindBaseCommitForFixupOnlyAddedLines, commit.FindBaseCommitForFixupWarningForAddedLines, commit.Highlight, commit.History, commit.HistoryComplex, commit.NewBranch, commit.PasteCommitMessage, commit.PasteCommitMessageOverExisting, commit.PreserveCommitMessage, commit.ResetAuthor, commit.ResetAuthorRange, commit.Revert, commit.RevertMerge, commit.RevertWithConflictMultipleCommits, commit.RevertWithConflictSingleCommit, commit.Reword, commit.Search, commit.SetAuthor, commit.SetAuthorRange, commit.StageRangeOfLines, commit.Staged, commit.StagedWithoutHooks, commit.Unstaged, config.CustomCommandsInPerRepoConfig, config.NegativeRefspec, config.RemoteNamedStar, conflicts.Filter, conflicts.ResolveExternally, conflicts.ResolveMultipleFiles, conflicts.ResolveNoAutoStage, conflicts.ResolveNonTextualConflicts, conflicts.ResolveWithoutTrailingLf, conflicts.UndoChooseHunk, custom_commands.AccessCommitProperties, custom_commands.BasicCommand, custom_commands.CheckForConflicts, custom_commands.CustomCommandsSubmenu, custom_commands.FormPrompts, custom_commands.GlobalContext, custom_commands.MenuFromCommand, custom_commands.MenuFromCommandsOutput, custom_commands.MultipleContexts, custom_commands.MultiplePrompts, custom_commands.RunCommand, custom_commands.SelectedCommit, custom_commands.SelectedCommitRange, custom_commands.SelectedPath, custom_commands.ShowOutputInPanel, custom_commands.SuggestionsCommand, custom_commands.SuggestionsPreset, demo.AmendOldCommit, demo.Bisect, demo.CherryPick, demo.CommitAndPush, demo.CommitGraph, demo.CustomCommand, demo.CustomPatch, demo.DiffCommits, demo.Filter, demo.InteractiveRebase, demo.NukeWorkingTree, demo.RebaseOnto, demo.StageLines, demo.Undo, demo.WorktreeCreateFromBranches, diff.CopyToClipboard, diff.Diff, diff.DiffAndApplyPatch, diff.DiffCommits, diff.DiffNonStickyRange, diff.IgnoreWhitespace, diff.RenameSimilarityThresholdChange, file.CollapseExpand, file.CopyMenu, file.DirWithUntrackedFile, file.DiscardAllDirChanges, file.DiscardRangeSelect, file.DiscardStagedChanges, file.DiscardUnstagedDirChanges, file.DiscardUnstagedFileChanges, file.DiscardUnstagedRangeSelect, file.DiscardVariousChanges, file.DiscardVariousChangesRangeSelect, file.Gitignore, file.GitignoreSpecialCharacters, file.RememberCommitMessageAfterFail, file.RenameSimilarityThresholdChange, file.RenamedFiles, file.StageChildrenRangeSelect, file.StageDeletedRangeSelect, file.StageRangeSelect, filter_and_search.FilterByFileStatus, filter_and_search.FilterCommitFiles, filter_and_search.FilterFiles, filter_and_search.FilterFuzzy, filter_and_search.FilterMenu, filter_and_search.FilterMenuCancelFilterWithEscape, filter_and_search.FilterMenuWithNoKeybindings, filter_and_search.FilterRemoteBranches, filter_and_search.FilterRemotes, filter_and_search.FilterSearchHistory, filter_and_search.FilterUpdatesWhenModelChanges, filter_and_search.NestedFilter, filter_and_search.NestedFilterTransient, filter_and_search.NewSearch, filter_and_search.StagingFolderStagesOnlyTrackedFilesInTrackedOnlyFilter, filter_by_author.SelectAuthor, filter_by_author.TypeAuthor, filter_by_path.CliArg, filter_by_path.KeepSameCommitSelectedOnExit, filter_by_path.SelectFile, filter_by_path.TypeFile, interactive_rebase.AdvancedInteractiveRebase, interactive_rebase.AmendCommitWithConflict, interactive_rebase.AmendFirstCommit, interactive_rebase.AmendFixupCommit, interactive_rebase.AmendHeadCommitDuringRebase, interactive_rebase.AmendMerge, interactive_rebase.AmendNonHeadCommitDuringRebase, interactive_rebase.DeleteUpdateRefTodo, interactive_rebase.DontShowBranchHeadsForTodoItems, interactive_rebase.DropCommitInCopiedBranchWithUpdateRef, interactive_rebase.DropMergeCommit, interactive_rebase.DropTodoCommitWithUpdateRef, interactive_rebase.DropWithCustomCommentChar, interactive_rebase.EditAndAutoAmend, interactive_rebase.EditFirstCommit, interactive_rebase.EditLastCommitOfStackedBranch, interactive_rebase.EditNonTodoCommitDuringRebase, interactive_rebase.EditRangeSelectDownToMergeOutsideRebase, interactive_rebase.EditRangeSelectOutsideRebase, interactive_rebase.EditTheConflCommit, interactive_rebase.FixupFirstCommit, interactive_rebase.FixupSecondCommit, interactive_rebase.InteractiveRebaseOfCopiedBranch, interactive_rebase.InteractiveRebaseWithConflictForEditCommand, interactive_rebase.MidRebaseRangeSelect, interactive_rebase.Move, interactive_rebase.MoveAcrossBranchBoundaryOutsideRebase, interactive_rebase.MoveInRebase, interactive_rebase.MoveUpdateRefTodo, interactive_rebase.MoveWithCustomCommentChar, interactive_rebase.OutsideRebaseRangeSelect, interactive_rebase.PickRescheduled, interactive_rebase.QuickStart, interactive_rebase.QuickStartKeepSelection, interactive_rebase.QuickStartKeepSelectionRange, interactive_rebase.Rebase, interactive_rebase.RebaseWithCommitThatBecomesEmpty, interactive_rebase.RevertDuringRebaseWhenStoppedOnEdit, interactive_rebase.RevertMultipleCommitsInInteractiveRebase, interactive_rebase.RevertSingleCommitInInteractiveRebase, interactive_rebase.RewordCommitWithEditorAndFail, interactive_rebase.RewordFirstCommit, interactive_rebase.RewordLastCommit, interactive_rebase.RewordYouAreHereCommit, interactive_rebase.RewordYouAreHereCommitWithEditor, interactive_rebase.ShowExecTodos, interactive_rebase.SquashDownFirstCommit, interactive_rebase.SquashDownSecondCommit, interactive_rebase.SquashFixupsAbove, interactive_rebase.SquashFixupsAboveFirstCommit, interactive_rebase.SquashFixupsInCurrentBranch, interactive_rebase.SwapInRebaseWithConflict, interactive_rebase.SwapInRebaseWithConflictAndEdit, interactive_rebase.SwapWithConflict, interactive_rebase.ViewFilesOfTodoEntries, misc.ConfirmOnQuit, misc.CopyToClipboard, misc.DisabledKeybindings, misc.InitialOpen, misc.RecentReposOnLaunch, patch_building.Apply, patch_building.ApplyInReverse, patch_building.ApplyInReverseWithConflict, patch_building.EditLineInPatchBuildingPanel, patch_building.MoveRangeToIndex, patch_building.MoveToEarlierCommit, patch_building.MoveToEarlierCommitFromAddedFile, patch_building.MoveToEarlierCommitNoKeepEmpty, patch_building.MoveToIndex, patch_building.MoveToIndexFromAddedFileWithConflict, patch_building.MoveToIndexPartOfAdjacentAddedLines, patch_building.MoveToIndexPartial, patch_building.MoveToIndexWithConflict, patch_building.MoveToIndexWorksEvenIfNoprefixIsSet, patch_building.MoveToLaterCommit, patch_building.MoveToLaterCommitPartialHunk, patch_building.MoveToNewCommit, patch_building.MoveToNewCommitFromAddedFile, patch_building.MoveToNewCommitFromDeletedFile, patch_building.MoveToNewCommitPartialHunk, patch_building.RemoveFromCommit, patch_building.RemovePartsOfAddedFile, patch_building.ResetWithEscape, patch_building.SelectAllFiles, patch_building.SpecificSelection, patch_building.StartNewPatch, patch_building.ToggleRange, reflog.Checkout, reflog.CherryPick, reflog.DoNotShowBranchMarkersInReflogSubcommits, reflog.Patch, reflog.Reset, shell_commands.BasicShellCommand, shell_commands.ComplexShellCommand, shell_commands.DeleteFromHistory, shell_commands.EditHistory, shell_commands.History, shell_commands.OmitFromHistory, staging.DiffChangeScreenMode, staging.DiffContextChange, staging.DiscardAllChanges, staging.Search, staging.StageHunks, staging.StageLines, staging.StageRanges, stash.Apply, stash.ApplyPatch, stash.CreateBranch, stash.Drop, stash.DropMultiple, stash.Pop, stash.PreventDiscardingFileChanges, stash.Rename, stash.Stash, stash.StashAll, stash.StashAndKeepIndex, stash.StashIncludingUntrackedFiles, stash.StashStaged, stash.StashStagedPartialFile, stash.StashUnstaged, status.ClickRepoNameToOpenReposMenu, status.ClickToFocus, status.ClickWorkingTreeStateToOpenRebaseOptionsMenu, status.LogCmd, status.ShowDivergenceFromBaseBranch, submodule.Add, submodule.Enter, submodule.EnterNested, submodule.Remove, submodule.RemoveNested, submodule.Reset, submodule.ResetFolder, sync.FetchAndAutoForwardBranchesAllBranches, sync.FetchAndAutoForwardBranchesNone, sync.FetchAndAutoForwardBranchesOnlyMainBranches, sync.FetchPrune, sync.FetchWhenSortedByDate, sync.ForcePush, sync.ForcePushMultipleMatching, sync.ForcePushMultipleUpstream, sync.ForcePushRemoteBranchNotStoredLocally, sync.ForcePushTriangular, sync.Pull, sync.PullAndSetUpstream, sync.PullMerge, sync.PullMergeConflict, sync.PullRebase, sync.PullRebaseConflict, sync.PullRebaseInteractiveConflict, sync.PullRebaseInteractiveConflictDrop, sync.Push, sync.PushAndAutoSetUpstream, sync.PushAndSetUpstream, sync.PushFollowTags, sync.PushNoFollowTags, sync.PushTag, sync.PushWithCredentialPrompt, sync.RenameBranchAndPull, tag.Checkout, tag.CheckoutWhenBranchWithSameNameExists, tag.CopyToClipboard, tag.CreateWhileCommitting, tag.CrudAnnotated, tag.CrudLightweight, tag.DeleteLocalAndRemote, tag.ForceTagAnnotated, tag.ForceTagLightweight, tag.Reset, ui.Accordion, ui.DisableSwitchTabWithPanelJumpKeys, ui.EmptyMenu, ui.KeybindingSuggestionsWhenSwitchingRepos, ui.ModeSpecificKeybindingSuggestions, ui.OpenLinkFailure, ui.RangeSelect, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, undo.UndoCheckoutAndDrop, undo.UndoCommit, undo.UndoDrop, worktree.AddFromBranch, worktree.AddFromBranchDetached, worktree.AddFromCommit, worktree.AssociateBranchBisect, worktree.AssociateBranchRebase, worktree.BareRepo, worktree.BareRepoWorktreeConfig, worktree.Crud, worktree.CustomCommand, worktree.DetachWorktreeFromBranch, worktree.DotfileBareRepo, worktree.DoubleNestedLinkedSubmodule, worktree.ExcludeFileInWorktree, worktree.FastForwardWorktreeBranch, worktree.FastForwardWorktreeBranchShouldNotPolluteCurrentWorktree, worktree.ForceRemoveWorktree, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, worktree.SymlinkIntoRepoSubdir, worktree.WorktreeInRepo, } lazygit-0.50.0+ds1/pkg/integration/tests/test_list_generator.go000066400000000000000000000062111500612110400246050ustar00rootroot00000000000000//go:build ignore // This file is invoked with `go generate ./...` and it generates the test_list.go file // The test_list.go file is a list of all the integration tests. // It's annoying to have to manually add an entry in that file for each test you // create, so this generator is here to make the process easier. package main import ( "bytes" "fmt" "go/format" "io/fs" "os" "strings" "github.com/samber/lo" ) func main() { println("Generating test_list.go...") code := generateCode() formattedCode, err := format.Source(code) if err != nil { panic(err) } if err := os.WriteFile("test_list.go", formattedCode, 0o644); err != nil { panic(err) } } func generateCode() []byte { // traverse parent directory to get all sibling directories directories, err := os.ReadDir("../tests") if err != nil { panic(err) } directories = lo.Filter(directories, func(file fs.DirEntry, _ int) bool { // 'shared' is a special folder containing shared test code so we // ignore it here return file.IsDir() && file.Name() != "shared" }) var buf bytes.Buffer fmt.Fprintf(&buf, "// THIS FILE IS AUTO-GENERATED. You can regenerate it by running `go generate ./...` at the root of the lazygit repo.\n\n") fmt.Fprintf(&buf, "package tests\n\n") fmt.Fprintf(&buf, "import (\n") fmt.Fprintf(&buf, "\t\"github.com/jesseduffield/lazygit/pkg/integration/components\"\n") for _, dir := range directories { fmt.Fprintf(&buf, "\t\"github.com/jesseduffield/lazygit/pkg/integration/tests/%s\"\n", dir.Name()) } fmt.Fprintf(&buf, ")\n\n") fmt.Fprintf(&buf, "var tests = []*components.IntegrationTest{\n") for _, dir := range directories { appendDirTests(dir, &buf) } fmt.Fprintf(&buf, "}\n") return buf.Bytes() } func appendDirTests(dir fs.DirEntry, buf *bytes.Buffer) { files, err := os.ReadDir(fmt.Sprintf("../tests/%s", dir.Name())) if err != nil { panic(err) } for _, file := range files { if file.IsDir() || !strings.HasSuffix(file.Name(), ".go") { continue } testName := snakeToPascal( strings.TrimSuffix(file.Name(), ".go"), ) fileContents, err := os.ReadFile(fmt.Sprintf("../tests/%s/%s", dir.Name(), file.Name())) if err != nil { panic(err) } fileContentsStr := string(fileContents) if !strings.Contains(fileContentsStr, "NewIntegrationTest(") { // the file does not define a test so it probably just contains shared test code continue } if !strings.Contains(fileContentsStr, fmt.Sprintf("var %s = NewIntegrationTest(NewIntegrationTestArgs{", testName)) { panic(fmt.Sprintf("expected test %s to be defined in file %s. Perhaps you misspelt it? The name of the test should be the name of the file but converted from snake_case to PascalCase", testName, file.Name())) } fmt.Fprintf(buf, "\t%s.%s,\n", dir.Name(), testName) } } // thanks ChatGPT func snakeToPascal(s string) string { // Split the input string into words. words := strings.Split(s, "_") // Convert the first letter of each word to uppercase and concatenate them. var builder strings.Builder for _, w := range words { if len(w) > 0 { builder.WriteString(strings.ToUpper(w[:1])) builder.WriteString(w[1:]) } } return builder.String() } lazygit-0.50.0+ds1/pkg/integration/tests/tests.go000066400000000000000000000045271500612110400216770ustar00rootroot00000000000000//go:generate go run test_list_generator.go package tests import ( "fmt" "os" "path/filepath" "strings" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/samber/lo" ) func GetTests(lazygitRootDir string) []*components.IntegrationTest { // first we ensure that each test in this directory has actually been added to the above list. testCount := 0 testNamesSet := set.NewFromSlice(lo.Map( tests, func(test *components.IntegrationTest, _ int) string { return test.Name() }, )) missingTestNames := []string{} if err := filepath.Walk(filepath.Join(lazygitRootDir, "pkg/integration/tests"), func(path string, info os.FileInfo, err error) error { if !info.IsDir() && strings.HasSuffix(path, ".go") { // ignoring non-test files if filepath.Base(path) == "tests.go" || filepath.Base(path) == "test_list.go" || filepath.Base(path) == "test_list_generator.go" { return nil } // the shared directory won't itself contain tests: only shared helper functions if filepath.Base(filepath.Dir(path)) == "shared" { return nil } // any file named shared.go will also be ignored, because those files are only used for shared helper functions if filepath.Base(path) == "shared.go" { return nil } nameFromPath := components.TestNameFromFilePath(path) if !testNamesSet.Includes(nameFromPath) { missingTestNames = append(missingTestNames, nameFromPath) } testCount++ } return nil }); err != nil { panic(fmt.Sprintf("failed to walk tests: %v", err)) } if len(missingTestNames) > 0 { panic(fmt.Sprintf("The following tests are missing from the list of tests: %s. You need to add them to `pkg/integration/tests/test_list.go`. Use `go generate ./...` to regenerate the tests list.", strings.Join(missingTestNames, ", "))) } if testCount > len(tests) { panic("you have not added all of the tests to the tests list in `pkg/integration/tests/test_list.go`. Use `go generate ./...` to regenerate the tests list.") } else if testCount < len(tests) { panic("There are more tests in `pkg/integration/tests/test_list.go` than there are test files in the tests directory. Ensure that you only have one test per file and you haven't included the same test twice in the tests list. Use `go generate ./...` to regenerate the tests list.") } return tests } lazygit-0.50.0+ds1/pkg/integration/tests/ui/000077500000000000000000000000001500612110400206135ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/ui/accordion.go000066400000000000000000000060471500612110400231120ustar00rootroot00000000000000package ui import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // When in accordion mode, Lazygit looks like this: // // ╶─Status─────────────────────────╴┌─Patch──────────────────────────────────────────────────────────┐ // ╶─Files - Submodules──────0 of 0─╴│commit 6e56dd04b70e548976f7f2928c4d9c359574e2bc ▲ // ╶─Local branches - Remotes1 of 1─╴│Author: CI █ // ┌─Commits - Reflog───────────────┐│Date: Wed Jul 19 22:00:03 2023 +1000 │ // │7fe02805 CI commit 12 ▲│ ▼ // │6e56dd04 CI commit 11 █└────────────────────────────────────────────────────────────────┘ // │a35c687d CI commit 10 ▼┌─Command log────────────────────────────────────────────────────┐ // └───────────────────────10 of 20─┘│Random tip: To filter commits by path, press '' │ // ╶─Stash───────────────────0 of 0─╴└────────────────────────────────────────────────────────────────┘ // /: Scroll, : Cancel, q: Quit, ?: Keybindings, 1-Donate Ask Question unversioned var Accordion = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify accordion mode kicks in when the screen height is too small", ExtraCmdArgs: []string{}, Width: 100, Height: 10, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateNCommits(20) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). VisibleLines( Contains("commit 20").IsSelected(), Contains("commit 19"), Contains("commit 18"), ). // go past commit 11, then come back, so that it ends up in the centre of the viewport NavigateToLine(Contains("commit 11")). NavigateToLine(Contains("commit 10")). NavigateToLine(Contains("commit 11")). VisibleLines( Contains("commit 12"), Contains("commit 11").IsSelected(), Contains("commit 10"), ) t.Views().Files(). Focus() // ensure we retain the same viewport upon re-focus t.Views().Commits(). Focus(). VisibleLines( Contains("commit 12"), Contains("commit 11").IsSelected(), Contains("commit 10"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go000066400000000000000000000014511500612110400312550ustar00rootroot00000000000000package ui import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DisableSwitchTabWithPanelJumpKeys = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that the tab does not change by default when jumping to an already focused panel", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { }, SetupRepo: func(shell *Shell) { }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status().Focus(). Press(keys.Universal.JumpToBlock[1]) t.Views().Files().IsFocused(). Press(keys.Universal.JumpToBlock[1]) // Despite jumping to an already focused panel, // the tab should not change from the base files view t.Views().Files().IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/ui/empty_menu.go000066400000000000000000000017321500612110400233270ustar00rootroot00000000000000package ui import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var EmptyMenu = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that we don't crash on an empty menu", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). Press(keys.Universal.OptionMenu) t.Views().Menu(). IsFocused(). // a string that filters everything out FilterOrSearch("ljasldkjaslkdjalskdjalsdjaslkd"). IsEmpty(). Press(keys.Universal.Select). Tap(func() { t.ExpectToast(Equals("Disabled: No item selected")) }). // escape the search PressEscape(). // escape the view PressEscape() // back in the files view, selecting the non-existing menu item was a no-op t.Views().Files(). IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/ui/keybinding_suggestions_when_switching_repos.go000066400000000000000000000023541500612110400322330ustar00rootroot00000000000000package ui import ( "path/filepath" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var KeybindingSuggestionsWhenSwitchingRepos = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Show correct keybinding suggestions after switching between repos", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { otherRepo, _ := filepath.Abs("../other") config.GetAppState().RecentRepos = []string{otherRepo} }, SetupRepo: func(shell *Shell) { shell.CloneNonBare("other") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { switchToRepo := func(repo string) { t.GlobalPress(keys.Universal.OpenRecentRepos) t.ExpectPopup().Menu().Title(Equals("Recent repositories")). Lines( Contains(repo).IsSelected(), Contains("Cancel"), ).Confirm() t.Views().Status().Content(Contains(repo + " → master")) } t.Views().Files().Focus() t.Views().Options().Content( Equals("Commit: c | Stash: s | Reset: D | Keybindings: ? | Cancel: ")) switchToRepo("other") switchToRepo("repo") t.Views().Options().Content( Equals("Commit: c | Stash: s | Reset: D | Keybindings: ? | Cancel: ")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go000066400000000000000000000066641500612110400306040ustar00rootroot00000000000000package ui import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" ) var ModeSpecificKeybindingSuggestions = NewIntegrationTest(NewIntegrationTestArgs{ Description: "When in various modes, we should corresponding keybinding suggestions onscreen", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateNCommits(2) shell.NewBranch("base-branch") shared.MergeConflictsSetup(shell) shell.Checkout("base-branch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { rebaseSuggestion := "View rebase options: m" cherryPickSuggestion := "Paste (cherry-pick): V" bisectSuggestion := "View bisect options: b" customPatchSuggestion := "View custom patch options: " mergeSuggestion := "View merge options: m" t.Views().Commits(). Focus(). Lines( Contains("commit 02").IsSelected(), Contains("commit 01"), ). Tap(func() { // These suggestions are mode-specific so are not shown by default t.Views().Options().Content( DoesNotContain(rebaseSuggestion). DoesNotContain(mergeSuggestion). DoesNotContain(cherryPickSuggestion). DoesNotContain(bisectSuggestion). DoesNotContain(customPatchSuggestion), ) }). // Start an interactive rebase Press(keys.Universal.Edit). Tap(func() { // Confirm the rebase suggestion now appears t.Views().Options().Content(Contains(rebaseSuggestion)) }). Press(keys.Commits.CherryPickCopy). Tap(func() { // Confirm the cherry pick suggestion now appears t.Views().Options().Content(Contains(cherryPickSuggestion)) // Importantly, we show multiple of these suggestions at once t.Views().Options().Content(Contains(rebaseSuggestion)) }). // Cancel the cherry pick PressEscape(). Tap(func() { t.Views().Options().Content(DoesNotContain(cherryPickSuggestion)) }). // Cancel the rebase Tap(func() { t.Common().AbortRebase() t.Views().Options().Content(DoesNotContain(rebaseSuggestion)) }). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Bisect")). Select(MatchesRegexp("Mark.* as bad")). Confirm() t.Views().Options().Content(Contains(bisectSuggestion)) // Cancel bisect t.Common().ResetBisect() t.Views().Options().Content(DoesNotContain(bisectSuggestion)) }). // Enter commit files view PressEnter() t.Views().CommitFiles(). IsFocused(). // Add a commit file to the patch Press(keys.Universal.Select). Tap(func() { t.Views().Options().Content(Contains(customPatchSuggestion)) t.Common().ResetCustomPatch() t.Views().Options().Content(DoesNotContain(customPatchSuggestion)) }) // Test merge options suggestion t.Views().Branches(). Focus(). NavigateToLine(Contains("first-change-branch")). Press(keys.Universal.Select). NavigateToLine(Contains("second-change-branch")). Press(keys.Branches.MergeIntoCurrentBranch). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Merge")). Select(Contains("Regular merge")). Confirm() t.Common().AcknowledgeConflicts() t.Views().Options().Content(Contains(mergeSuggestion)) t.Common().AbortMerge() t.Views().Options().Content(DoesNotContain(mergeSuggestion)) }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/ui/open_link_failure.go000066400000000000000000000013561500612110400246340ustar00rootroot00000000000000package ui import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var OpenLinkFailure = NewIntegrationTest(NewIntegrationTestArgs{ Description: "When opening links via the OS fails, show a dialog instead.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().OS.OpenLink = "exit 42" }, SetupRepo: func(shell *Shell) {}, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Information().Click(0, 0) t.ExpectPopup().Confirmation(). Title(Equals("Error")). Content(Equals("Failed to open URL https://github.com/sponsors/jesseduffield\n\nError: exit status 42")). Confirm() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/ui/range_select.go000066400000000000000000000126001500612110400235740ustar00rootroot00000000000000package ui import ( "fmt" "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // Here's the state machine we need to verify: // (no range, press 'v') -> sticky range // (no range, press arrow) -> no range // (no range, press shift+arrow) -> nonsticky range // (sticky range, press 'v') -> no range // (sticky range, press 'escape') -> no range // (sticky range, press arrow) -> sticky range // (sticky range, press `<`/`>` or `,`/`.`) -> sticky range // (sticky range, press shift+arrow) -> nonsticky range // (nonsticky range, press 'v') -> no range // (nonsticky range, press 'escape') -> no range // (nonsticky range, press arrow) -> no range // (nonsticky range, press shift+arrow) -> nonsticky range // Importantly, if you press 'v' when in a nonsticky range, it clears the range, // so no matter which mode you're in, 'v' will cancel the range. // And, if you press shift+up/down when in a sticky range, it switches to a non- // sticky range, meaning if you then press up/down without shift, it clears // the range. var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify range select works as expected in list views and in patch explorer views", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // We're testing the commits view as our representative list context, // as well as the staging view, and we're using the exact same code to test // both to ensure they have the exact same behaviour (they are currently implemented // separately) // In both views we're going to have 10 lines starting from 'line 1' going down to // 'line 10'. fileContent := "staged\n" total := 10 for i := 1; i <= total; i++ { remaining := total - i + 1 // Commits are displayed in reverse order so to we need to create them in reverse to have them appear as 'line 1', 'line 2' etc. shell.EmptyCommit(fmt.Sprintf("line %d", remaining)) fileContent = fmt.Sprintf("%sline %d\n", fileContent, i) } shell.CreateFileAndAdd("file1", "staged\n") shell.UpdateFile("file1", fileContent) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { assertRangeSelectBehaviour := func(v *ViewDriver, focusOtherView func(), lineIdxOfFirstItem int) { v. SelectedLines( Contains("line 1"), ). // (no range, press 'v') -> sticky range Press(keys.Universal.ToggleRangeSelect). SelectedLines( Contains("line 1"), ). // (sticky range, press arrow) -> sticky range SelectNextItem(). SelectedLines( Contains("line 1"), Contains("line 2"), ). // (sticky range, press 'v') -> no range Press(keys.Universal.ToggleRangeSelect). SelectedLines( Contains("line 2"), ). // (no range, press arrow) -> no range SelectPreviousItem(). SelectedLines( Contains("line 1"), ). // (no range, press shift+arrow) -> nonsticky range Press(keys.Universal.RangeSelectDown). SelectedLines( Contains("line 1"), Contains("line 2"), ). // (nonsticky range, press shift+arrow) -> nonsticky range Press(keys.Universal.RangeSelectDown). SelectedLines( Contains("line 1"), Contains("line 2"), Contains("line 3"), ). Press(keys.Universal.RangeSelectUp). SelectedLines( Contains("line 1"), Contains("line 2"), ). // (nonsticky range, press arrow) -> no range SelectNextItem(). SelectedLines( Contains("line 3"), ). Press(keys.Universal.ToggleRangeSelect). SelectedLines( Contains("line 3"), ). SelectNextItem(). SelectedLines( Contains("line 3"), Contains("line 4"), ). // (sticky range, press shift+arrow) -> nonsticky range Press(keys.Universal.RangeSelectDown). SelectedLines( Contains("line 3"), Contains("line 4"), Contains("line 5"), ). SelectNextItem(). SelectedLines( Contains("line 6"), ). Press(keys.Universal.RangeSelectDown). SelectedLines( Contains("line 6"), Contains("line 7"), ). // (nonsticky range, press 'v') -> no range Press(keys.Universal.ToggleRangeSelect). SelectedLines( Contains("line 7"), ). Press(keys.Universal.RangeSelectDown). SelectedLines( Contains("line 7"), Contains("line 8"), ). // (nonsticky range, press 'escape') -> no range PressEscape(). SelectedLines( Contains("line 8"), ). // (sticky range, press '>') -> sticky range Press(keys.Universal.ToggleRangeSelect). Press(keys.Universal.GotoBottom). SelectedLines( Contains("line 8"), Contains("line 9"), Contains("line 10"), ). // (sticky range, press 'escape') -> no range PressEscape(). SelectedLines( Contains("line 10"), ) // Click in view, press shift+arrow -> nonsticky range focusOtherView() v.Click(1, lineIdxOfFirstItem). SelectedLines( Contains("line 1"), ). Press(keys.Universal.RangeSelectDown). SelectedLines( Contains("line 1"), Contains("line 2"), ) } assertRangeSelectBehaviour(t.Views().Commits().Focus(), func() { t.Views().Branches().Focus() }, 0) t.Views().Files(). Focus(). SelectedLine( Contains("file1"), ). PressEnter() assertRangeSelectBehaviour(t.Views().Staging().IsFocused(), func() { t.Views().Staging().PressTab() }, 6) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/ui/switch_tab_from_menu.go000066400000000000000000000012321500612110400253360ustar00rootroot00000000000000package ui import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SwitchTabFromMenu = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Switch tab via the options menu", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsFocused(). Press(keys.Universal.OptionMenuAlt1) t.ExpectPopup().Menu().Title(Equals("Keybindings")). Select(Contains("Next tab")). Confirm() t.Views().Worktrees().IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go000066400000000000000000000020251500612110400275700ustar00rootroot00000000000000package ui import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SwitchTabWithPanelJumpKeys = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Switch tab with the panel jump keys after enabling the feature", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.SwitchTabsWithPanelJumpKeys = true }, SetupRepo: func(shell *Shell) { }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Worktrees().Focus(). Press(keys.Universal.JumpToBlock[2]) t.Views().Branches().IsFocused(). Press(keys.Universal.JumpToBlock[2]) t.Views().Remotes().IsFocused(). Press(keys.Universal.JumpToBlock[2]) t.Views().Tags().IsFocused(). Press(keys.Universal.JumpToBlock[2]) t.Views().Branches().IsFocused(). Press(keys.Universal.JumpToBlock[1]) // When jumping to a panel from a different one, keep its current tab: t.Views().Worktrees().IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/undo/000077500000000000000000000000001500612110400211435ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/undo/undo_checkout_and_drop.go000066400000000000000000000072331500612110400261770ustar00rootroot00000000000000package undo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var UndoCheckoutAndDrop = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Drop some commits and then undo/redo the actions", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.EmptyCommit("three") shell.EmptyCommit("four") shell.NewBranch("other_branch") shell.Checkout("master") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // we're going to drop a commit, switch branch, drop a commit there, then undo everything, then redo everything. confirmCommitDrop := func() { t.ExpectPopup().Confirmation(). Title(Equals("Drop commit")). Content(Equals("Are you sure you want to drop the selected commit(s)?")). Confirm() } confirmUndoDrop := func() { t.ExpectPopup().Confirmation(). Title(Equals("Undo")). Content(MatchesRegexp(`Are you sure you want to hard reset to '.*'\? An auto-stash will be performed if necessary\.`)). Confirm() } confirmRedoDrop := func() { t.ExpectPopup().Confirmation(). Title(Equals("Redo")). Content(MatchesRegexp(`Are you sure you want to hard reset to '.*'\? An auto-stash will be performed if necessary\.`)). Confirm() } t.Views().Commits().Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.Remove). Tap(confirmCommitDrop). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ) t.Views().Branches().Focus(). Lines( Contains("master").IsSelected(), Contains("other_branch"), ). SelectNextItem(). // checkout branch PressPrimaryAction(). Lines( Contains("other_branch").IsSelected(), Contains("master"), ) // drop the commit in the 'other_branch' branch too t.Views().Commits().Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.Remove). Tap(confirmCommitDrop). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ). Press(keys.Universal.Undo). Tap(confirmUndoDrop). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.Undo). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Undo")). Content(Contains("Are you sure you want to checkout 'master'?")). Confirm() t.Views().Branches(). Lines( Contains("master").IsSelected(), Contains("other_branch"), ) }). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ). Press(keys.Universal.Undo). Tap(confirmUndoDrop). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.Redo). Tap(confirmRedoDrop). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ). Press(keys.Universal.Redo). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Redo")). Content(Contains("Are you sure you want to checkout 'other_branch'?")). Confirm() t.Views().Branches(). Lines( Contains("other_branch").IsSelected(), Contains("master"), ) }). Press(keys.Universal.Redo). Tap(confirmRedoDrop). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/undo/undo_commit.go000066400000000000000000000047001500612110400240100ustar00rootroot00000000000000package undo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var UndoCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Undo/redo a commit", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("other-file", "other-file-1") shell.Commit("one") shell.CreateFileAndAdd("file", "file-1") shell.Commit("two") shell.UpdateFile("other-file", "other-file-2") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { confirmUndo := func() { t.ExpectPopup().Confirmation(). Title(Equals("Undo")). Content(MatchesRegexp(`Are you sure you want to soft reset to '.*'\?`)). Confirm() } confirmRedo := func() { t.ExpectPopup().Confirmation(). Title(Equals("Redo")). Content(MatchesRegexp(`Are you sure you want to hard reset to '.*'\? An auto-stash will be performed if necessary\.`)). Confirm() } confirmDiscardFile := func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() } t.Views().Files(). Lines( Contains(" M other-file"), ) t.Views().Commits().Focus(). Lines( Contains("two").IsSelected(), Contains("one"), ). Press(keys.Universal.Undo). Tap(confirmUndo). Lines( Contains("one").IsSelected(), ) t.Views().Files(). Lines( Equals("▼ /"), Equals(" A file"), Equals(" M other-file"), ) t.Views().Commits().Focus(). Press(keys.Universal.Redo). Tap(confirmRedo). Lines( Contains("two").IsSelected(), Contains("one"), ) t.Views().Files(). Lines( Equals(" M other-file"), ) // Undo again, this time discarding the original change before redoing again t.Views().Commits().Focus(). Press(keys.Universal.Undo). Tap(confirmUndo). Lines( Contains("one").IsSelected(), ) t.Views().Files().Focus(). Lines( Equals("▼ /"), Equals(" A file"), Equals(" M other-file").IsSelected(), ). Press(keys.Universal.PrevItem). Press(keys.Universal.Remove). Tap(confirmDiscardFile). Lines( Equals(" M other-file"), ). Press(keys.Universal.Redo). Tap(confirmRedo) t.Views().Commits(). Lines( Contains("two"), Contains("one"), ) t.Views().Files(). Lines( Equals(" M other-file"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/undo/undo_drop.go000066400000000000000000000043111500612110400234620ustar00rootroot00000000000000package undo import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var UndoDrop = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Drop some commits and then undo/redo the actions", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("one") shell.EmptyCommit("two") shell.EmptyCommit("three") shell.EmptyCommit("four") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { confirmCommitDrop := func() { t.ExpectPopup().Confirmation(). Title(Equals("Drop commit")). Content(Equals("Are you sure you want to drop the selected commit(s)?")). Confirm() } confirmUndo := func() { t.ExpectPopup().Confirmation(). Title(Equals("Undo")). Content(MatchesRegexp(`Are you sure you want to hard reset to '.*'\? An auto-stash will be performed if necessary\.`)). Confirm() } confirmRedo := func() { t.ExpectPopup().Confirmation(). Title(Equals("Redo")). Content(MatchesRegexp(`Are you sure you want to hard reset to '.*'\? An auto-stash will be performed if necessary\.`)). Confirm() } t.Views().Commits().Focus(). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.Remove). Tap(confirmCommitDrop). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ). Press(keys.Universal.Remove). Tap(confirmCommitDrop). Lines( Contains("two").IsSelected(), Contains("one"), ). Press(keys.Universal.Undo). Tap(confirmUndo). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ). Press(keys.Universal.Undo). Tap(confirmUndo). Lines( Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.Redo). Tap(confirmRedo). Lines( Contains("three").IsSelected(), Contains("two"), Contains("one"), ). Press(keys.Universal.Redo). Tap(confirmRedo). Lines( Contains("two").IsSelected(), Contains("one"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/000077500000000000000000000000001500612110400220405ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/worktree/add_from_branch.go000066400000000000000000000035131500612110400254610ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AddFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add a worktree via the branches view, then switch back to the main worktree via the branches view", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("mybranch"), ). Press(keys.Worktrees.ViewWorktreeOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Worktree")). Select(Contains(`Create worktree from mybranch`).DoesNotContain("detached")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). Type("../linked-worktree"). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New branch name")). Type("newbranch"). Confirm() }). // confirm we're still focused on the branches view IsFocused(). Lines( Contains("newbranch").IsSelected(), Contains("mybranch (worktree)"), ). NavigateToLine(Contains("mybranch")). Press(keys.Universal.Select). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Switch to worktree")). Content(Equals("This branch is checked out by worktree repo. Do you want to switch to that worktree?")). Confirm() }). Lines( Contains("mybranch").IsSelected(), Contains("newbranch (worktree)"), ). // Confirm the files view is still showing in the files window Press(keys.Universal.PrevBlock) t.Views().Files(). IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/add_from_branch_detached.go000066400000000000000000000023251500612110400273020ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AddFromBranchDetached = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add a detached worktree via the branches view", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("mybranch"), ). Press(keys.Worktrees.ViewWorktreeOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Worktree")). Select(Contains(`Create worktree from mybranch (detached)`)). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). Type("../linked-worktree"). Confirm() }). // confirm we're still focused on the branches view IsFocused(). Lines( Contains("(no branch)").IsSelected(), Contains("mybranch (worktree)"), ) t.Views().Status(). Content(Contains("repo(linked-worktree)")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/add_from_commit.go000066400000000000000000000026641500612110400255220ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var AddFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add a worktree via the commits view", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("commit two") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( Contains("commit two").IsSelected(), Contains("initial commit"), ). NavigateToLine(Contains("initial commit")). Press(keys.Worktrees.ViewWorktreeOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Worktree")). Select(MatchesRegexp(`Create worktree from .*`).DoesNotContain("detached")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). Type("../linked-worktree"). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New branch name")). Type("newbranch"). Confirm() }). Lines( Contains("initial commit"), ) // Confirm we're now in the branches view t.Views().Branches(). IsFocused(). Lines( Contains("newbranch").IsSelected(), Contains("mybranch (worktree)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/associate_branch_bisect.go000066400000000000000000000054721500612110400272200ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // This is important because `git worktree list` will show a worktree being in a detached head state (which is true) // when it's in the middle of a bisect, but it won't tell you about the branch it's on. // Even so, if you attempt to check out that branch from another worktree git won't let you, so we need to // keep track of the association ourselves. // not bothering to test the linked worktree here because it's the same logic as the rebase test var AssociateBranchBisect = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that when you start a bisect in a linked worktree, Lazygit still associates the worktree with the branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("mybranch").IsSelected(), Contains("newbranch (worktree)"), ) // start a bisect on the main worktree t.Views().Commits(). Focus(). SelectedLine(Contains("commit 3")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Bisect")). Select(MatchesRegexp(`Mark .* as bad`)). Confirm() t.Views().Information().Content(Contains("Bisecting")) }). NavigateToLine(Contains("initial commit")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Bisect")). Select(MatchesRegexp(`Mark .* as good`)). Confirm() }) t.Views().Branches(). Focus(). // switch to linked worktree NavigateToLine(Contains("newbranch")). Press(keys.Universal.Select). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Switch to worktree")). Content(Equals("This branch is checked out by worktree linked-worktree. Do you want to switch to that worktree?")). Confirm() t.Views().Information().Content(DoesNotContain("Bisecting")) }). Lines( Contains("newbranch").IsSelected(), Contains("mybranch (worktree)"), ) // switch back to main worktree t.Views().Branches(). Focus(). NavigateToLine(Contains("mybranch")). Press(keys.Universal.Select). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Switch to worktree")). Content(Equals("This branch is checked out by worktree repo. Do you want to switch to that worktree?")). Confirm() }) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/associate_branch_rebase.go000066400000000000000000000056211500612110400272040ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // This is important because `git worktree list` will show a worktree being in a detached head state (which is true) // when it's in the middle of a rebase, but it won't tell you about the branch it's on. // Even so, if you attempt to check out that branch from another worktree git won't let you, so we need to // keep track of the association ourselves. // We need different logic for associated the branch depending on whether it's a main worktree or // linked worktree, so this test handles both. var AssociateBranchRebase = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that when you start a rebase in a linked or main worktree, Lazygit still associates the worktree with the branch", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("mybranch").IsSelected(), Contains("newbranch (worktree)"), ) // start a rebase on the main worktree t.Views().Commits(). Focus(). NavigateToLine(Contains("commit 2")). Press(keys.Universal.Edit) t.Views().Information().Content(Contains("Rebasing")) t.Views().Branches(). Focus(). // switch to linked worktree NavigateToLine(Contains("newbranch")). Press(keys.Universal.Select). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Switch to worktree")). Content(Equals("This branch is checked out by worktree linked-worktree. Do you want to switch to that worktree?")). Confirm() t.Views().Information().Content(DoesNotContain("Rebasing")) }). Lines( Contains("newbranch").IsSelected(), Contains("mybranch (worktree)"), ) // start a rebase on the linked worktree t.Views().Commits(). Focus(). NavigateToLine(Contains("commit 2")). Press(keys.Universal.Edit) t.Views().Information().Content(Contains("Rebasing")) // switch back to main worktree t.Views().Branches(). Focus(). NavigateToLine(Contains("mybranch")). Press(keys.Universal.Select). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Switch to worktree")). Content(Equals("This branch is checked out by worktree repo. Do you want to switch to that worktree?")). Confirm() }). Lines( Contains("(no branch").IsSelected(), Contains("mybranch"), // even though the linked worktree is rebasing, we still associate it with the branch Contains("newbranch (worktree)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/bare_repo.go000066400000000000000000000056401500612110400243320ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var BareRepo = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open lazygit in the worktree of a bare repo and do a rebase/bisect", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // we're going to have a directory structure like this: // project // - .bare // - repo (a worktree) // - worktree2 (another worktree) // // The first repo is called 'repo' because that's the // directory that all lazygit tests start in shell.NewBranch("mybranch") shell.CreateFileAndAdd("blah", "blah") shell.Commit("initial commit") shell.EmptyCommit("commit two") shell.EmptyCommit("commit three") shell.RunCommand([]string{"git", "clone", "--bare", ".", "../.bare"}) shell.DeleteFile(".git") shell.Chdir("..") // This is the dir we were just in (and the dir that lazygit starts in when the test runs) // We're going to replace it with a worktree shell.DeleteFile("repo") shell.RunCommand([]string{"git", "--git-dir", ".bare", "worktree", "add", "-b", "repo", "repo", "mybranch"}) shell.RunCommand([]string{"git", "--git-dir", ".bare", "worktree", "add", "-b", "worktree2", "worktree2", "mybranch"}) shell.Chdir("repo") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("repo"), Contains("mybranch"), Contains("worktree2 (worktree)"), ) // test that a rebase works fine // (rebase uses the git dir of the worktree so we're confirming that it points // to the right git dir) t.Views().Commits(). Focus(). Lines( Contains("commit three").IsSelected(), Contains("commit two"), Contains("initial commit"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("commit two"), Contains("commit three").IsSelected(), Contains("initial commit"), ). // test that bisect works fine (same logic as above) NavigateToLine(Contains("commit two")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Bisect")). Select(MatchesRegexp(`Mark .* as bad`)). Confirm() t.Views().Information().Content(Contains("Bisecting")) }). NavigateToLine(Contains("initial commit")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Bisect")). Select(MatchesRegexp(`Mark .* as good`)). Confirm() t.Views().Information().Content(Contains("Bisecting")) }) // switch to other worktree t.Views().Worktrees(). Focus(). Lines( Contains("repo").IsSelected(), Contains("worktree2"), ). NavigateToLine(Contains("worktree2")). Press(keys.Universal.Select). Lines( Contains("worktree2").IsSelected(), Contains("repo"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/bare_repo_worktree_config.go000066400000000000000000000057731500612110400276100ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // This case is identical to dotfile_bare_repo.go, except // that it invokes lazygit with $GIT_DIR set but not // $GIT_WORK_TREE. Instead, the repo uses the core.worktree // config to identify the main worktree. var BareRepoWorktreeConfig = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open lazygit in the worktree of a vcsh-style bare repo and add a file and commit", ExtraCmdArgs: []string{"--git-dir={{.actualPath}}/.bare"}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.ShowFileTree = false }, SetupRepo: func(shell *Shell) { // we're going to have a directory structure like this: // project // - .bare // - . (a worktree at the same path as .bare) // // // 'repo' is the repository/directory that all lazygit tests start in shell.CreateFileAndAdd("a/b/c/blah", "blah\n") shell.Commit("initial commit") shell.CreateFileAndAdd(".gitignore", ".bare/\n/repo\n") shell.Commit("add .gitignore") shell.Chdir("..") // configure this "fake bare"" repo using the vcsh convention // of core.bare=false and core.worktree set to the actual // worktree path (a homedir root). This allows $GIT_DIR // alone to make this repo "self worktree identifying" shell.RunCommand([]string{"git", "--git-dir=./.bare", "init", "--shared=false"}) shell.RunCommand([]string{"git", "--git-dir=./.bare", "config", "core.bare", "false"}) shell.RunCommand([]string{"git", "--git-dir=./.bare", "config", "core.worktree", ".."}) shell.RunCommand([]string{"git", "--git-dir=./.bare", "remote", "add", "origin", "./repo"}) shell.RunCommand([]string{"git", "--git-dir=./.bare", "checkout", "-b", "main"}) shell.RunCommand([]string{"git", "--git-dir=./.bare", "config", "branch.main.remote", "origin"}) shell.RunCommand([]string{"git", "--git-dir=./.bare", "config", "branch.main.merge", "refs/heads/master"}) shell.RunCommand([]string{"git", "--git-dir=./.bare", "fetch", "origin", "master"}) shell.RunCommand([]string{"git", "--git-dir=./.bare", "-c", "merge.ff=true", "merge", "origin/master"}) // we no longer need the original repo so remove it shell.DeleteFile("repo") shell.UpdateFile("a/b/c/blah", "updated content\n") shell.Chdir("a/b/c") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("main"), ) t.Views().Commits(). Lines( Contains("add .gitignore"), Contains("initial commit"), ) t.Views().Files(). IsFocused(). Lines( Contains(" M a/b/c/blah"), // shows as modified ). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). Type("Add blah"). Confirm() t.Views().Files(). IsEmpty() t.Views().Commits(). Lines( Contains("Add blah"), Contains("add .gitignore"), Contains("initial commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/crud.go000066400000000000000000000057321500612110400233330ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var Crud = NewIntegrationTest(NewIntegrationTestArgs{ Description: "From the worktrees view, add a work tree, switch to it, switch back, and remove it", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("mybranch"), ) t.Views().Status(). Lines( Contains("repo → mybranch"), ) t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)"), ). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Worktree")). Select(Contains(`Create worktree from ref`).DoesNotContain(("detached"))). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree base ref")). InitialText(Equals("mybranch")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). Type("../linked-worktree"). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New branch name (leave blank to checkout mybranch)")). Type("newbranch"). Confirm() }). Lines( Contains("linked-worktree").IsSelected(), Contains("repo (main)"), ). // confirm we're still in the same view IsFocused() // status panel includes the worktree if it's a linked worktree t.Views().Status(). Lines( Contains("repo(linked-worktree) → newbranch"), ) t.Views().Branches(). Lines( Contains("newbranch"), Contains("mybranch"), ) t.Views().Worktrees(). // confirm we can't remove the current worktree Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Equals("You cannot remove the current worktree!")). Confirm() }). // confirm we cannot remove the main worktree NavigateToLine(Contains("repo (main)")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Equals("You cannot remove the main worktree!")). Confirm() }). // switch back to main worktree Press(keys.Universal.Select). Lines( Contains("repo (main)").IsSelected(), Contains("linked-worktree"), ) t.Views().Branches(). Lines( Contains("mybranch"), Contains("newbranch"), ) t.Views().Worktrees(). // remove linked worktree NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Remove worktree")). Content(Contains("Are you sure you want to remove worktree 'linked-worktree'?")). Confirm() }). Lines( Contains("repo (main)").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/custom_command.go000066400000000000000000000021321500612110400253750ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var CustomCommand = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that custom commands work with worktrees by deleting a worktree via a custom command", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { Key: "d", Context: "worktrees", Command: "git worktree remove {{ .SelectedWorktree.Path | quote }}", }, } }, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)"), Contains("linked-worktree"), ). NavigateToLine(Contains("linked-worktree")). Press("d"). Lines( Contains("repo (main)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/detach_worktree_from_branch.go000066400000000000000000000027621500612110400301100ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Detach a worktree from the branches view", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("mybranch").IsSelected(), Contains("newbranch (worktree)"), ). NavigateToLine(Contains("newbranch")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'newbranch'?")). Select(Contains("Delete local branch")). Confirm() }). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Branch newbranch is checked out by worktree linked-worktree")). Select(Equals("Detach worktree")). Confirm() }). Lines( Contains("mybranch"), Contains("newbranch").DoesNotContain("(worktree)").IsSelected(), ) t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)").IsSelected(), Contains("linked-worktree"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/dotfile_bare_repo.go000066400000000000000000000040561500612110400260400ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // Can't think of a better name than 'dotfile' repo: I'm using that // because that's the case we're typically dealing with. var DotfileBareRepo = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open lazygit in the worktree of a dotfile bare repo and add a file and commit", ExtraCmdArgs: []string{"--git-dir={{.actualPath}}/.bare", "--work-tree={{.actualPath}}/repo"}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // we're going to have a directory structure like this: // project // - .bare // - repo (the worktree) // // The first repo is called 'repo' because that's the // directory that all lazygit tests start in // Delete the .git dir that all tests start with by default shell.DeleteFile(".git") // Create a bare repo in the parent directory shell.RunCommand([]string{"git", "init", "--bare", "../.bare"}) shell.RunCommand([]string{"git", "--git-dir=../.bare", "--work-tree=.", "checkout", "-b", "mybranch"}) shell.CreateFile("blah", "original content\n") // Add a file and commit shell.RunCommand([]string{"git", "--git-dir=../.bare", "--work-tree=.", "add", "blah"}) shell.RunCommand([]string{"git", "--git-dir=../.bare", "--work-tree=.", "commit", "-m", "initial commit"}) shell.UpdateFile("blah", "updated content\n") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("mybranch"), ) t.Views().Commits(). Lines( Contains("initial commit"), ) t.Views().Files(). IsFocused(). Lines( Contains(" M blah"), // shows as modified ). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). Type("Add blah"). Confirm() t.Views().Files(). IsEmpty() t.Views().Commits(). Lines( Contains("Add blah"), Contains("initial commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/double_nested_linked_submodule.go000066400000000000000000000053641500612110400306200ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // Even though this involves submodules, it's a worktree test since // it's really exercising lazygit's ability to correctly do pathfinding // in a complex use case. var DoubleNestedLinkedSubmodule = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open lazygit in a link to a repo's double nested submodules", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.ShowFileTree = false }, SetupRepo: func(shell *Shell) { // we're going to have a directory structure like this: // project // - repo/outerSubmodule/innerSubmodule/a/b/c // - link (symlink to repo/outerSubmodule/innerSubmodule/a/b/c) // shell.CreateFileAndAdd("rootFile", "rootStuff") shell.Commit("initial repo commit") shell.Chdir("..") shell.CreateDir("innerSubmodule") shell.Chdir("innerSubmodule") shell.Init() shell.CreateFileAndAdd("a/b/c/blah", "blah\n") shell.Commit("initial inner commit") shell.Chdir("..") shell.CreateDir("outerSubmodule") shell.Chdir("outerSubmodule") shell.Init() shell.CreateFileAndAdd("foo", "foo") shell.Commit("initial outer commit") // the git config (-c) parameter below is required // to let git create a file-protocol/path submodule shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "add", "../innerSubmodule"}) shell.Commit("add dependency as innerSubmodule") shell.Chdir("../repo") shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "add", "../outerSubmodule"}) shell.Commit("add dependency as outerSubmodule") shell.Chdir("outerSubmodule") shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "update", "--init", "--recursive"}) shell.Chdir("innerSubmodule") shell.UpdateFile("a/b/c/blah", "updated content\n") shell.Chdir("../../..") shell.RunCommand([]string{"ln", "-s", "repo/outerSubmodule/innerSubmodule/a/b/c", "link"}) shell.Chdir("link") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("HEAD detached"), Contains("master"), ) t.Views().Commits(). Lines( Contains("initial inner commit"), ) t.Views().Files(). IsFocused(). Lines( Contains(" M a/b/c/blah"), // shows as modified ). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). Type("Update blah"). Confirm() t.Views().Files(). IsEmpty() t.Views().Commits(). Lines( Contains("Update blah"), Contains("initial inner commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/exclude_file_in_worktree.go000066400000000000000000000021751500612110400274340ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ExcludeFileInWorktree = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add a file to .git/info/exclude in a worktree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.EmptyCommit("commit1") shell.AddWorktree("HEAD", "../linked-worktree", "mybranch") shell.CreateFile("../linked-worktree/toExclude", "") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)").IsSelected(), Contains("linked-worktree"), ). SelectNextItem(). PressPrimaryAction() t.Views().Files(). Focus(). Lines( Contains("toExclude"), ). Press(keys.Files.IgnoreFile). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")).Select(Contains("Add to .git/info/exclude")).Confirm() }). IsEmpty() t.FileSystem().FileContent("../repo/.git/info/exclude", Contains("toExclude")) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/fast_forward_worktree_branch.go000066400000000000000000000032301500612110400303050ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FastForwardWorktreeBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Fast-forward a linked worktree branch from another worktree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // both main and linked worktree will have changed to fast-forward shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("two") shell.EmptyCommit("three") shell.NewBranch("newbranch") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("mybranch", "origin/mybranch") shell.SetBranchUpstream("newbranch", "origin/newbranch") // remove the 'three' commit so that we have something to pull from the remote shell.HardReset("HEAD^") shell.Checkout("mybranch") shell.HardReset("HEAD^") shell.AddWorktreeCheckout("newbranch", "../linked-worktree") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("mybranch").Contains("↓1").IsSelected(), Contains("newbranch (worktree)").Contains("↓1"), ). Press(keys.Branches.FastForward). Lines( Contains("mybranch").Contains("✓").IsSelected(), Contains("newbranch (worktree)").Contains("↓1"), ). NavigateToLine(Contains("newbranch (worktree)")). Press(keys.Branches.FastForward). Lines( Contains("mybranch").Contains("✓"), Contains("newbranch (worktree)").Contains("✓").IsSelected(), ) }, }) fast_forward_worktree_branch_should_not_pollute_current_worktree.go000066400000000000000000000036161500612110400377240ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/tests/worktreepackage worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var FastForwardWorktreeBranchShouldNotPolluteCurrentWorktree = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Fast-forward a linked worktree branch from another worktree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { // both main and linked worktree will have changed to fast-forward shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("two") shell.EmptyCommit("three") shell.NewBranch("newbranch") shell.CloneIntoRemote("origin") shell.SetBranchUpstream("mybranch", "origin/mybranch") shell.SetBranchUpstream("newbranch", "origin/newbranch") // remove the 'three' commit so that we have something to pull from the remote shell.HardReset("HEAD^") shell.Checkout("mybranch") shell.HardReset("HEAD^") shell.AddWorktreeCheckout("newbranch", "../linked-worktree") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("mybranch").Contains("↓1").IsSelected(), Contains("newbranch (worktree)").Contains("↓1"), ). Press(keys.Branches.FastForward). Lines( Contains("mybranch").Contains("✓").IsSelected(), Contains("newbranch (worktree)").Contains("↓1"), ). NavigateToLine(Contains("newbranch (worktree)")). Press(keys.Branches.FastForward). Lines( Contains("mybranch").Contains("✓"), Contains("newbranch (worktree)").Contains("✓").IsSelected(), ). NavigateToLine(Contains("mybranch")) // check the current worktree that it has no lines in the File changes pane t.Views().Files(). Focus(). Press(keys.Files.RefreshFiles). LineCount(EqualsInt(0)) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/force_remove_worktree.go000066400000000000000000000026711500612110400267720ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var ForceRemoveWorktree = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Force remove a dirty worktree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") shell.AddFileInWorktree("../linked-worktree") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)").IsSelected(), Contains("linked-worktree"), ). NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Remove worktree")). Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")). Confirm() t.ExpectPopup().Confirmation(). Title(Equals("Remove worktree")). Content(Equals("'linked-worktree' contains modified or untracked files (to be honest, it could contain both). Are you sure you want to remove it?")). Confirm() }). Lines( Contains("repo (main)").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/remove_worktree_from_branch.go000066400000000000000000000036471500612110400301600ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Remove a worktree from the branches view", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") shell.AddFileInWorktree("../linked-worktree") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). Lines( Contains("mybranch").IsSelected(), Contains("newbranch (worktree)"), ). NavigateToLine(Contains("newbranch")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). Menu(). Title(Equals("Delete branch 'newbranch'?")). Select(Contains("Delete local branch")). Confirm() }). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Branch newbranch is checked out by worktree linked-worktree")). Select(Equals("Remove worktree")). Confirm() t.ExpectPopup().Confirmation(). Title(Equals("Remove worktree")). Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")). Confirm() t.ExpectPopup().Confirmation(). Title(Equals("Remove worktree")). Content(Equals("'linked-worktree' contains modified or untracked files (to be honest, it could contain both). Are you sure you want to remove it?")). Confirm() }). Lines( Contains("mybranch"), Contains("newbranch").DoesNotContain("(worktree)").IsSelected(), ) t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)").IsSelected(), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/reset_window_tabs.go000066400000000000000000000033711500612110400261150ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) // This is verifying logic that is subject to change (we're just doing the easiest approach) // There are two other UX flows we could have: // 1) associate window tab states with the repo, so that when you switch back to a repo you get the same window tab states // 2) retain the same window tab states when switching repos // Option 1 is straightforward, but option 2 is harder because you'd need to deactivate any views containing dependent // content e.g. the sub-commits view. var ResetWindowTabs = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Verify that window tabs are reset whenever switching repos", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") shell.AddFileInWorktree("../linked-worktree") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // focus the remotes tab i.e. the second tab in the branches window t.Views().Remotes(). Focus() t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)").IsSelected(), Contains("linked-worktree"), ). NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Select). Lines( Contains("linked-worktree").IsSelected(), Contains("repo (main)"), ). // navigate back to the branches window Press(keys.Universal.NextBlock) t.Views().Branches(). IsFocused() }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/symlink_into_repo_subdir.go000066400000000000000000000027131500612110400275060ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var SymlinkIntoRepoSubdir = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Open lazygit in a symlink into a repo's subdirectory", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.ShowFileTree = false }, SetupRepo: func(shell *Shell) { // we're going to have a directory structure like this: // project // - repo/a/b/c (main worktree with subdirs) // - link (symlink to repo/a/b/c) // shell.CreateFileAndAdd("a/b/c/blah", "blah\n") shell.Commit("initial commit") shell.UpdateFile("a/b/c/blah", "updated content\n") shell.Chdir("..") shell.RunCommand([]string{"ln", "-s", "repo/a/b/c", "link"}) shell.Chdir("link") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("master"), ) t.Views().Commits(). Lines( Contains("initial commit"), ) t.Views().Files(). IsFocused(). Lines( Contains(" M a/b/c/blah"), // shows as modified ). PressPrimaryAction(). Press(keys.Files.CommitChanges) t.ExpectPopup().CommitMessagePanel(). Title(Equals("Commit summary")). Type("Add blah"). Confirm() t.Views().Files(). IsEmpty() t.Views().Commits(). Lines( Contains("Add blah"), Contains("initial commit"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/tests/worktree/worktree_in_repo.go000066400000000000000000000041251500612110400257460ustar00rootroot00000000000000package worktree import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Add a worktree inside the repo, then remove the directory and confirm the worktree is removed", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { shell.NewBranch("mybranch") shell.CreateFileAndAdd("README.md", "hello world") shell.Commit("initial commit") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Lines( Contains("mybranch"), ) t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)"), ). Press(keys.Universal.New). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Worktree")). Select(Contains(`Create worktree from ref`).DoesNotContain(("detached"))). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree base ref")). InitialText(Equals("mybranch")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). Type("linked-worktree"). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New branch name (leave blank to checkout mybranch)")). Type("newbranch"). Confirm() }). Lines( Contains("linked-worktree").IsSelected(), Contains("repo (main)"), ). // switch back to main worktree NavigateToLine(Contains("repo (main)")). Press(keys.Universal.Select). Lines( Contains("repo (main)").IsSelected(), Contains("linked-worktree"), ) t.Views().Files(). Focus(). Lines( Contains("linked-worktree"), ). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Discard changes")). Select(Contains("Discard all changes")). Confirm() }). IsEmpty() // confirm worktree appears as missing t.Views().Worktrees(). Focus(). Lines( Contains("repo (main)").IsSelected(), Contains("linked-worktree (missing)"), ) }, }) lazygit-0.50.0+ds1/pkg/integration/types/000077500000000000000000000000001500612110400202005ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/integration/types/types.go000066400000000000000000000032451500612110400216770ustar00rootroot00000000000000package types import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" ) // these interfaces are used by the gui package so that it knows what it needs // to provide to a test in order for the test to run. type IntegrationTest interface { Run(GuiDriver) SetupConfig(config *config.AppConfig) RequiresHeadless() bool // width and height when running headless HeadlessDimensions() (int, int) // If true, we are recording/replaying a demo IsDemo() bool } // this is the interface through which our integration tests interact with the lazygit gui type GuiDriver interface { PressKey(string) Click(int, int) Keys() config.KeybindingConfig CurrentContext() types.Context ContextForView(viewName string) types.Context Fail(message string) // These two log methods are for the sake of debugging while testing. There's no need to actually // commit any logging. // logs to the normal place that you log to i.e. viewable with `lazygit --logs` Log(message string) // logs in the actual UI (in the commands panel) LogUI(message string) CheckedOutRef() *models.Branch // the view that appears to the right of the side panel MainView() *gocui.View // the other view that sometimes appears to the right of the side panel // e.g. when we're showing both staged and unstaged changes SecondaryView() *gocui.View View(viewName string) *gocui.View SetCaption(caption string) SetCaptionPrefix(prefix string) // Pop the next toast that was displayed; returns nil if there was none NextToast() *string CheckAllToastsAcknowledged() Headless() bool } lazygit-0.50.0+ds1/pkg/jsonschema/000077500000000000000000000000001500612110400166435ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/jsonschema/generate.go000066400000000000000000000076111500612110400207710ustar00rootroot00000000000000//go:generate go run generator.go package jsonschema import ( "encoding/json" "fmt" "os" "reflect" "strings" "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/config" "github.com/karimkhaleel/jsonschema" "github.com/samber/lo" ) func GetSchemaDir() string { return utils.GetLazyRootDirectory() + "/schema" } func GenerateSchema() *jsonschema.Schema { schema := customReflect(&config.UserConfig{}) obj, _ := json.MarshalIndent(schema, "", " ") obj = append(obj, '\n') if err := os.WriteFile(GetSchemaDir()+"/config.json", obj, 0o644); err != nil { fmt.Println("Error writing to file:", err) return nil } return schema } func getSubSchema(rootSchema, parentSchema *jsonschema.Schema, key string) *jsonschema.Schema { subSchema, found := parentSchema.Properties.Get(key) if !found { panic(fmt.Sprintf("Failed to find subSchema at %s on parent", key)) } // This means the schema is defined on the rootSchema's Definitions if subSchema.Ref != "" { key, _ = strings.CutPrefix(subSchema.Ref, "#/$defs/") refSchema, ok := rootSchema.Definitions[key] if !ok { panic(fmt.Sprintf("Failed to find #/$defs/%s", key)) } refSchema.Description = subSchema.Description return refSchema } return subSchema } func customReflect(v *config.UserConfig) *jsonschema.Schema { r := &jsonschema.Reflector{FieldNameTag: "yaml", RequiredFromJSONSchemaTags: true} if err := r.AddGoComments("github.com/jesseduffield/lazygit/pkg/config", "../config"); err != nil { panic(err) } filterOutDevComments(r) schema := r.Reflect(v) defaultConfig := config.GetDefaultConfig() userConfigSchema := schema.Definitions["UserConfig"] defaultValue := reflect.ValueOf(defaultConfig).Elem() yamlToFieldNames := lo.Invert(userConfigSchema.OriginalPropertiesMapping) for pair := userConfigSchema.Properties.Oldest(); pair != nil; pair = pair.Next() { yamlName := pair.Key fieldName := yamlToFieldNames[yamlName] subSchema := getSubSchema(schema, userConfigSchema, yamlName) setDefaultVals(schema, subSchema, defaultValue.FieldByName(fieldName).Interface()) } return schema } func filterOutDevComments(r *jsonschema.Reflector) { for k, v := range r.CommentMap { commentLines := strings.Split(v, "\n") filteredCommentLines := lo.Filter(commentLines, func(line string, _ int) bool { return !strings.Contains(line, "[dev]") }) r.CommentMap[k] = strings.Join(filteredCommentLines, "\n") } } func setDefaultVals(rootSchema, schema *jsonschema.Schema, defaults any) { t := reflect.TypeOf(defaults) v := reflect.ValueOf(defaults) if t.Kind() == reflect.Ptr || t.Kind() == reflect.Interface { t = t.Elem() v = v.Elem() } k := t.Kind() _ = k switch t.Kind() { case reflect.Bool: schema.Default = v.Bool() case reflect.Int: schema.Default = v.Int() case reflect.String: schema.Default = v.String() default: // Do nothing } if t.Kind() != reflect.Struct { return } for i := 0; i < t.NumField(); i++ { value := v.Field(i).Interface() parentKey := t.Field(i).Name key, ok := schema.OriginalPropertiesMapping[parentKey] if !ok { continue } subSchema := getSubSchema(rootSchema, schema, key) if isStruct(value) { setDefaultVals(rootSchema, subSchema, value) } else if !isZeroValue(value) { subSchema.Default = value } } } func isZeroValue(v any) bool { switch v := v.(type) { case int, int32, int64, float32, float64: return v == 0 case string: return v == "" case bool: return false case nil: return true } rv := reflect.ValueOf(v) switch rv.Kind() { case reflect.Slice, reflect.Map: return rv.Len() == 0 case reflect.Ptr, reflect.Interface: return rv.IsNil() case reflect.Struct: for i := 0; i < rv.NumField(); i++ { if !isZeroValue(rv.Field(i).Interface()) { return false } } return true default: return false } } func isStruct(v any) bool { return reflect.TypeOf(v).Kind() == reflect.Struct } lazygit-0.50.0+ds1/pkg/jsonschema/generate_config_docs.go000066400000000000000000000137021500612110400233240ustar00rootroot00000000000000package jsonschema import ( "bytes" "errors" "fmt" "os" "strings" "github.com/jesseduffield/lazycore/pkg/utils" "github.com/karimkhaleel/jsonschema" "github.com/samber/lo" "gopkg.in/yaml.v3" ) type Node struct { Name string Description string Default any Children []*Node } const ( IndentLevel = 2 DocumentationCommentStart = "\n" DocumentationCommentEnd = "" DocumentationCommentStartLen = len(DocumentationCommentStart) ) func insertBlankLines(buffer bytes.Buffer) bytes.Buffer { lines := strings.Split(strings.TrimRight(buffer.String(), "\n"), "\n") var newBuffer bytes.Buffer previousIndent := -1 wasComment := false for _, line := range lines { trimmedLine := strings.TrimLeft(line, " ") indent := len(line) - len(trimmedLine) isComment := strings.HasPrefix(trimmedLine, "#") if isComment && !wasComment && indent <= previousIndent { newBuffer.WriteString("\n") } newBuffer.WriteString(line) newBuffer.WriteString("\n") previousIndent = indent wasComment = isComment } return newBuffer } func prepareMarshalledConfig(buffer bytes.Buffer) []byte { buffer = insertBlankLines(buffer) // Remove all `---` lines lines := strings.Split(strings.TrimRight(buffer.String(), "\n"), "\n") var newBuffer bytes.Buffer for _, line := range lines { if strings.TrimSpace(line) != "---" { newBuffer.WriteString(line) newBuffer.WriteString("\n") } } config := newBuffer.Bytes() // Add markdown yaml block tag config = append([]byte("```yaml\n"), config...) config = append(config, []byte("```\n")...) return config } func setComment(yamlNode *yaml.Node, description string) { // Workaround for the way yaml formats the HeadComment if it contains // blank lines: it renders these without a leading "#", but we want a // leading "#" even on blank lines. However, yaml respects it if the // HeadComment already contains a leading "#", so we prefix all lines // (including blank ones) with "#". yamlNode.HeadComment = strings.Join( lo.Map(strings.Split(description, "\n"), func(s string, _ int) string { if s == "" { return "#" // avoid trailing space on blank lines } return "# " + s }), "\n") } func (n *Node) MarshalYAML() (interface{}, error) { node := yaml.Node{ Kind: yaml.MappingNode, } keyNode := yaml.Node{ Kind: yaml.ScalarNode, Value: n.Name, } if n.Description != "" { setComment(&keyNode, n.Description) } if len(n.Children) > 0 { childrenNode := yaml.Node{ Kind: yaml.MappingNode, } for _, child := range n.Children { childYaml, err := child.MarshalYAML() if err != nil { return nil, err } childKey := yaml.Node{ Kind: yaml.ScalarNode, Value: child.Name, } if child.Description != "" { setComment(&childKey, child.Description) } childYaml = childYaml.(*yaml.Node) childrenNode.Content = append(childrenNode.Content, childYaml.(*yaml.Node).Content...) } node.Content = append(node.Content, &keyNode, &childrenNode) } else { valueNode := yaml.Node{ Kind: yaml.ScalarNode, } err := valueNode.Encode(n.Default) if err != nil { return nil, err } node.Content = append(node.Content, &keyNode, &valueNode) } return &node, nil } func writeToConfigDocs(config []byte) error { configPath := utils.GetLazyRootDirectory() + "/docs/Config.md" markdown, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("Error reading Config.md file %w", err) } startConfigSectionIndex := bytes.Index(markdown, []byte(DocumentationCommentStart)) if startConfigSectionIndex == -1 { return errors.New("Default config starting comment not found") } endConfigSectionIndex := bytes.Index(markdown[startConfigSectionIndex+DocumentationCommentStartLen:], []byte(DocumentationCommentEnd)) if endConfigSectionIndex == -1 { return errors.New("Default config closing comment not found") } endConfigSectionIndex = endConfigSectionIndex + startConfigSectionIndex + DocumentationCommentStartLen newMarkdown := make([]byte, 0, len(markdown)-endConfigSectionIndex+startConfigSectionIndex+len(config)) newMarkdown = append(newMarkdown, markdown[:startConfigSectionIndex+DocumentationCommentStartLen]...) newMarkdown = append(newMarkdown, config...) newMarkdown = append(newMarkdown, markdown[endConfigSectionIndex:]...) if err := os.WriteFile(configPath, newMarkdown, 0o644); err != nil { return fmt.Errorf("Error writing to file %w", err) } return nil } func GenerateConfigDocs(schema *jsonschema.Schema) { rootNode := &Node{ Children: make([]*Node, 0), } recurseOverSchema(schema, schema.Definitions["UserConfig"], rootNode) var buffer bytes.Buffer encoder := yaml.NewEncoder(&buffer) encoder.SetIndent(IndentLevel) for _, child := range rootNode.Children { err := encoder.Encode(child) if err != nil { panic("Failed to Marshal document") } } encoder.Close() config := prepareMarshalledConfig(buffer) err := writeToConfigDocs(config) if err != nil { panic(err) } } func recurseOverSchema(rootSchema, schema *jsonschema.Schema, parent *Node) { if schema == nil || schema.Properties == nil || schema.Properties.Len() == 0 { return } for pair := schema.Properties.Oldest(); pair != nil; pair = pair.Next() { subSchema := getSubSchema(rootSchema, schema, pair.Key) if strings.Contains(strings.ToLower(subSchema.Description), "deprecated") { continue } node := Node{ Name: pair.Key, Description: subSchema.Description, Default: getZeroValue(subSchema.Default, subSchema.Type), } parent.Children = append(parent.Children, &node) recurseOverSchema(rootSchema, subSchema, &node) } } func getZeroValue(val any, t string) any { if !isZeroValue(val) { return val } switch t { case "string": return "" case "boolean": return false case "object": return map[string]any{} case "array": return []any{} default: return nil } } lazygit-0.50.0+ds1/pkg/jsonschema/generator.go000066400000000000000000000004211500612110400211550ustar00rootroot00000000000000//go:build ignore package main import ( "fmt" "github.com/jesseduffield/lazygit/pkg/jsonschema" ) func main() { fmt.Printf("Generating jsonschema in %s...\n", jsonschema.GetSchemaDir()) schema := jsonschema.GenerateSchema() jsonschema.GenerateConfigDocs(schema) } lazygit-0.50.0+ds1/pkg/logs/000077500000000000000000000000001500612110400154555ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/logs/logs.go000066400000000000000000000033361500612110400167550ustar00rootroot00000000000000package logs import ( "io" "log" "os" "github.com/sirupsen/logrus" ) // It's important that this package does not depend on any other package because we // may want to import it from anywhere, and we don't want to create a circular dependency // (because Go refuses to compile circular dependencies). // Global is a global logger that can be used anywhere in the app, for // _development purposes only_. I want to avoid global variables when possible, // so if you want to log something that's printed when the -debug flag is set, // you'll need to ensure the struct you're working with has a logger field ( // and most of them do). // Global is only available if the LAZYGIT_LOG_PATH environment variable is set. var Global *logrus.Entry func init() { logPath := os.Getenv("LAZYGIT_LOG_PATH") if logPath != "" { Global = NewDevelopmentLogger(logPath) } } func NewProductionLogger() *logrus.Entry { logger := logrus.New() logger.Out = io.Discard logger.SetLevel(logrus.ErrorLevel) return formatted(logger) } func NewDevelopmentLogger(logPath string) *logrus.Entry { logger := logrus.New() logger.SetLevel(getLogLevel()) file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) if err != nil { log.Fatalf("Unable to log to log file: %v", err) } logger.SetOutput(file) return formatted(logger) } func formatted(log *logrus.Logger) *logrus.Entry { // highly recommended: tail -f development.log | humanlog // https://github.com/aybabtme/humanlog log.Formatter = &logrus.JSONFormatter{} return log.WithFields(logrus.Fields{}) } func getLogLevel() logrus.Level { strLevel := os.Getenv("LOG_LEVEL") level, err := logrus.ParseLevel(strLevel) if err != nil { return logrus.DebugLevel } return level } lazygit-0.50.0+ds1/pkg/logs/tail/000077500000000000000000000000001500612110400164065ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/logs/tail/logs_default.go000066400000000000000000000007621500612110400214120ustar00rootroot00000000000000//go:build !windows // +build !windows package tail import ( "log" "os" "os/exec" "github.com/aybabtme/humanlog" ) func tailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) { cmd := exec.Command("tail", "-f", logFilePath) stdout, _ := cmd.StdoutPipe() if err := cmd.Start(); err != nil { log.Fatal(err) } if err := humanlog.Scanner(stdout, os.Stdout, opts); err != nil { log.Fatal(err) } if err := cmd.Wait(); err != nil { log.Fatal(err) } os.Exit(0) } lazygit-0.50.0+ds1/pkg/logs/tail/logs_windows.go000066400000000000000000000025711500612110400214600ustar00rootroot00000000000000//go:build windows // +build windows package tail import ( "bufio" "log" "os" "strings" "time" "github.com/aybabtme/humanlog" ) func tailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) { var lastModified int64 = 0 var lastOffset int64 = 0 for { stat, err := os.Stat(logFilePath) if err != nil { log.Fatal(err) } if stat.ModTime().Unix() > lastModified { err = tailFrom(lastOffset, logFilePath, opts) if err != nil { log.Fatal(err) } } lastOffset = stat.Size() time.Sleep(1 * time.Second) } } func openAndSeek(filepath string, offset int64) (*os.File, error) { file, err := os.Open(filepath) if err != nil { return nil, err } _, err = file.Seek(offset, 0) if err != nil { _ = file.Close() return nil, err } return file, nil } func tailFrom(lastOffset int64, logFilePath string, opts *humanlog.HandlerOptions) error { file, err := openAndSeek(logFilePath, lastOffset) if err != nil { return err } fileScanner := bufio.NewScanner(file) var lines []string for fileScanner.Scan() { lines = append(lines, fileScanner.Text()) } file.Close() lineCount := len(lines) lastTen := lines if lineCount > 10 { lastTen = lines[lineCount-10:] } for _, line := range lastTen { reader := strings.NewReader(line) if err := humanlog.Scanner(reader, os.Stdout, opts); err != nil { log.Fatal(err) } } return nil } lazygit-0.50.0+ds1/pkg/logs/tail/tail.go000066400000000000000000000011201500612110400176600ustar00rootroot00000000000000package tail import ( "fmt" "log" "os" "github.com/aybabtme/humanlog" ) // TailLogs lets us run `lazygit --logs` to print the logs produced by other lazygit processes. // This makes for easier debugging. func TailLogs(logFilePath string) { fmt.Printf("Tailing log file %s\n\n", logFilePath) opts := humanlog.DefaultOptions opts.Truncates = false _, err := os.Stat(logFilePath) if err != nil { if os.IsNotExist(err) { log.Fatal("Log file does not exist. Run `lazygit --debug` first to create the log file") } log.Fatal(err) } tailLogsForPlatform(logFilePath, opts) } lazygit-0.50.0+ds1/pkg/snake/000077500000000000000000000000001500612110400156125ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/snake/snake.go000066400000000000000000000110211500612110400172350ustar00rootroot00000000000000package snake import ( "math/rand" "time" "github.com/samber/lo" ) type Game struct { // width/height of the board width int height int // function for rendering the game. If alive is false, the cells are expected // to be ignored. render func(cells [][]CellType, alive bool) // closed when the game is exited exit chan (struct{}) // channel for specifying the direction the player wants the snake to go in setNewDir chan (Direction) // allows logging for debugging logger func(string) // putting this on the struct for deterministic testing randIntFn func(int) int } type State struct { // first element is the head, final element is the tail snakePositions []Position foodPosition Position // direction of the snake direction Direction // direction as of the end of the last tick. We hold onto this so that // the snake can't do a 180 turn inbetween ticks lastTickDirection Direction } type Position struct { x int y int } type Direction int const ( Up Direction = iota Down Left Right ) type CellType int const ( None CellType = iota Snake Food ) func NewGame(width, height int, render func(cells [][]CellType, alive bool), logger func(string)) *Game { return &Game{ width: width, height: height, render: render, randIntFn: rand.Intn, exit: make(chan struct{}), logger: logger, setNewDir: make(chan Direction), } } func (self *Game) Start() { go self.gameLoop() } func (self *Game) Exit() { close(self.exit) } func (self *Game) SetDirection(direction Direction) { self.setNewDir <- direction } func (self *Game) gameLoop() { state := self.initializeState() var alive bool self.render(self.getCells(state), true) ticker := time.NewTicker(time.Duration(75) * time.Millisecond) for { select { case <-self.exit: return case dir := <-self.setNewDir: state.direction = self.newDirection(state, dir) case <-ticker.C: state, alive = self.tick(state) self.render(self.getCells(state), alive) if !alive { return } } } } func (self *Game) initializeState() State { centerOfScreen := Position{self.width / 2, self.height / 2} snakePositions := []Position{centerOfScreen} state := State{ snakePositions: snakePositions, direction: Right, foodPosition: self.newFoodPos(snakePositions), } return state } func (self *Game) newFoodPos(snakePositions []Position) Position { // arbitrarily setting a limit of attempts to place food attemptLimit := 1000 for i := 0; i < attemptLimit; i++ { newFoodPos := Position{self.randIntFn(self.width), self.randIntFn(self.height)} if !lo.Contains(snakePositions, newFoodPos) { return newFoodPos } } panic("SORRY, BUT I WAS TOO LAZY TO MAKE THE SNAKE GAME SMART ENOUGH TO PUT THE FOOD SOMEWHERE SENSIBLE NO MATTER WHAT, AND I ALSO WAS TOO LAZY TO ADD A WIN CONDITION") } // returns whether the snake is alive func (self *Game) tick(currentState State) (State, bool) { nextState := currentState // copy by value newHeadPos := nextState.snakePositions[0] nextState.lastTickDirection = nextState.direction switch nextState.direction { case Up: newHeadPos.y-- case Down: newHeadPos.y++ case Left: newHeadPos.x-- case Right: newHeadPos.x++ } outOfBounds := newHeadPos.x < 0 || newHeadPos.x >= self.width || newHeadPos.y < 0 || newHeadPos.y >= self.height eatingOwnTail := lo.Contains(nextState.snakePositions, newHeadPos) if outOfBounds || eatingOwnTail { return State{}, false } nextState.snakePositions = append([]Position{newHeadPos}, nextState.snakePositions...) if newHeadPos == nextState.foodPosition { nextState.foodPosition = self.newFoodPos(nextState.snakePositions) } else { nextState.snakePositions = nextState.snakePositions[:len(nextState.snakePositions)-1] } return nextState, true } func (self *Game) getCells(state State) [][]CellType { cells := make([][]CellType, self.height) setCell := func(pos Position, value CellType) { cells[pos.y][pos.x] = value } for i := 0; i < self.height; i++ { cells[i] = make([]CellType, self.width) } for _, pos := range state.snakePositions { setCell(pos, Snake) } setCell(state.foodPosition, Food) return cells } func (self *Game) newDirection(state State, direction Direction) Direction { // don't allow the snake to turn 180 degrees if (state.lastTickDirection == Up && direction == Down) || (state.lastTickDirection == Down && direction == Up) || (state.lastTickDirection == Left && direction == Right) || (state.lastTickDirection == Right && direction == Left) { return state.direction } return direction } lazygit-0.50.0+ds1/pkg/snake/snake_test.go000066400000000000000000000030411500612110400202770ustar00rootroot00000000000000package snake import ( "testing" "github.com/stretchr/testify/assert" ) func TestSnake(t *testing.T) { scenarios := []struct { state State expectedState State expectedAlive bool }{ { state: State{ snakePositions: []Position{{x: 5, y: 5}}, direction: Right, lastTickDirection: Right, foodPosition: Position{x: 9, y: 9}, }, expectedState: State{ snakePositions: []Position{{x: 6, y: 5}}, direction: Right, lastTickDirection: Right, foodPosition: Position{x: 9, y: 9}, }, expectedAlive: true, }, { state: State{ snakePositions: []Position{{x: 5, y: 5}, {x: 4, y: 5}, {x: 4, y: 4}, {x: 5, y: 4}}, direction: Up, lastTickDirection: Up, foodPosition: Position{x: 9, y: 9}, }, expectedState: State{}, expectedAlive: false, }, { state: State{ snakePositions: []Position{{x: 5, y: 5}}, direction: Right, lastTickDirection: Right, foodPosition: Position{x: 6, y: 5}, }, expectedState: State{ snakePositions: []Position{{x: 6, y: 5}, {x: 5, y: 5}}, direction: Right, lastTickDirection: Right, foodPosition: Position{x: 8, y: 8}, }, expectedAlive: true, }, } for _, scenario := range scenarios { game := NewGame(10, 10, nil, func(string) {}) game.randIntFn = func(int) int { return 8 } state, alive := game.tick(scenario.state) assert.Equal(t, scenario.expectedAlive, alive) assert.EqualValues(t, scenario.expectedState, state) } } lazygit-0.50.0+ds1/pkg/tasks/000077500000000000000000000000001500612110400156365ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/tasks/async_handler.go000066400000000000000000000030101500612110400207710ustar00rootroot00000000000000package tasks import ( "github.com/jesseduffield/gocui" "github.com/sasha-s/go-deadlock" ) // the purpose of an AsyncHandler is to ensure that if we have multiple long-running // requests, we only handle the result of the latest one. For example, if I am // searching for 'abc' and I have to type 'a' then 'b' then 'c' and each keypress // dispatches a request to search for things with the string so-far, we'll be searching // for 'a', 'ab', and 'abc', and it may be that 'abc' comes back first, then 'ab', // then 'a' and we don't want to display the result for 'a' just because it came // back last. AsyncHandler keeps track of the order in which things were dispatched // so that we can ignore anything that comes back late. type AsyncHandler struct { currentId int lastId int mutex deadlock.Mutex onReject func() onWorker func(func(gocui.Task) error) } func NewAsyncHandler(onWorker func(func(gocui.Task) error)) *AsyncHandler { return &AsyncHandler{ mutex: deadlock.Mutex{}, onWorker: onWorker, } } func (self *AsyncHandler) Do(f func() func()) { self.mutex.Lock() self.currentId++ id := self.currentId self.mutex.Unlock() self.onWorker(func(gocui.Task) error { after := f() self.handle(after, id) return nil }) } // f here is expected to be a function that doesn't take long to run func (self *AsyncHandler) handle(f func(), id int) { self.mutex.Lock() defer self.mutex.Unlock() if id < self.lastId { if self.onReject != nil { self.onReject() } return } self.lastId = id f() } lazygit-0.50.0+ds1/pkg/tasks/async_handler_test.go000066400000000000000000000013141500612110400220350ustar00rootroot00000000000000package tasks import ( "fmt" "sync" "testing" "github.com/jesseduffield/gocui" "github.com/stretchr/testify/assert" ) func TestAsyncHandler(t *testing.T) { wg := sync.WaitGroup{} wg.Add(2) onWorker := func(f func(gocui.Task) error) { go func() { _ = f(gocui.NewFakeTask()) }() } handler := NewAsyncHandler(onWorker) handler.onReject = func() { wg.Done() } result := 0 wg2 := sync.WaitGroup{} wg2.Add(1) handler.Do(func() func() { wg2.Wait() return func() { fmt.Println("setting to 1") result = 1 } }) handler.Do(func() func() { return func() { fmt.Println("setting to 2") result = 2 wg.Done() wg2.Done() } }) wg.Wait() assert.EqualValues(t, 2, result) } lazygit-0.50.0+ds1/pkg/tasks/tasks.go000066400000000000000000000252011500612110400173120ustar00rootroot00000000000000package tasks import ( "bufio" "fmt" "io" "os/exec" "strings" "sync" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" "github.com/sirupsen/logrus" ) // This file revolves around running commands that will be output to the main panel // in the gui. If we're flicking through the commits panel, we want to invoke a // `git show` command for each commit, but we don't want to read the entire output // at once (because that would slow things down); we just want to fill the panel // and then read more as the user scrolls down. We also want to ensure that we're only // ever running one `git show` command at time, and that we only have one command // writing its output to the main panel at a time. const THROTTLE_TIME = time.Millisecond * 30 // we use this to check if the system is under stress right now. Hopefully this makes sense on other machines const COMMAND_START_THRESHOLD = time.Millisecond * 10 type ViewBufferManager struct { // this blocks until the task has been properly stopped stopCurrentTask func() // this is what we write the output of the task to. It's typically a view writer io.Writer waitingMutex deadlock.Mutex taskIDMutex deadlock.Mutex Log *logrus.Entry newTaskID int readLines chan LinesToRead taskKey string onNewKey func() // beforeStart is the function that is called before starting a new task beforeStart func() refreshView func() onEndOfInput func() // see docs/dev/Busy.md // A gocui task is not the same thing as the tasks defined in this file. // A gocui task simply represents the fact that lazygit is busy doing something, // whereas the tasks in this file are about rendering content to a view. newGocuiTask func() gocui.Task // if the user flicks through a heap of items, with each one // spawning a process to render something to the main view, // it can slow things down quite a bit. In these situations we // want to throttle the spawning of processes. throttle bool } type LinesToRead struct { // Total number of lines to read Total int // Number of lines after which we have read enough to fill the view, and can // do an initial refresh. Only set for the initial read request; -1 for // subsequent requests. InitialRefreshAfter int // Function to call after reading the lines is done Then func() } func (m *ViewBufferManager) GetTaskKey() string { return m.taskKey } func NewViewBufferManager( log *logrus.Entry, writer io.Writer, beforeStart func(), refreshView func(), onEndOfInput func(), onNewKey func(), newGocuiTask func() gocui.Task, ) *ViewBufferManager { return &ViewBufferManager{ Log: log, writer: writer, beforeStart: beforeStart, refreshView: refreshView, onEndOfInput: onEndOfInput, readLines: nil, onNewKey: onNewKey, newGocuiTask: newGocuiTask, } } func (self *ViewBufferManager) ReadLines(n int) { if self.readLines != nil { go utils.Safe(func() { self.readLines <- LinesToRead{Total: n, InitialRefreshAfter: -1} }) } } func (self *ViewBufferManager) ReadToEnd(then func()) { if self.readLines != nil { go utils.Safe(func() { self.readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then} }) } else if then != nil { then() } } func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), prefix string, linesToRead LinesToRead, onDoneFn func()) func(TaskOpts) error { return func(opts TaskOpts) error { var onDoneOnce sync.Once var onFirstPageShownOnce sync.Once onFirstPageShown := func() { onFirstPageShownOnce.Do(func() { opts.InitialContentLoaded() }) } onDone := func() { if onDoneFn != nil { onDoneOnce.Do(onDoneFn) } onFirstPageShown() } if self.throttle { self.Log.Info("throttling task") time.Sleep(THROTTLE_TIME) } select { case <-opts.Stop: onDone() return nil default: } startTime := time.Now() cmd, r := start() timeToStart := time.Since(startTime) done := make(chan struct{}) go utils.Safe(func() { select { case <-done: // The command finished and did not have to be preemptively stopped before the next command. // No need to throttle. self.throttle = false case <-opts.Stop: // we use the time it took to start the program as a way of checking if things // are running slow at the moment. This is admittedly a crude estimate, but // the point is that we only want to throttle when things are running slow // and the user is flicking through a bunch of items. self.throttle = time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD // Kill the still-running command. if err := oscommands.Kill(cmd); err != nil { if !strings.Contains(err.Error(), "process already finished") { self.Log.Errorf("error when trying to kill cmd task: %v; Command: %v %v", err, cmd.Path, cmd.Args) } } // for pty's we need to call onDone here so that cmd.Wait() doesn't block forever onDone() } }) loadingMutex := deadlock.Mutex{} self.readLines = make(chan LinesToRead, 1024) scanner := bufio.NewScanner(r) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) lineChan := make(chan []byte) lineWrittenChan := make(chan struct{}) // We're reading from the scanner in a separate goroutine because on windows // if running git through a shim, we sometimes kill the parent process without // killing its children, meaning the scanner blocks forever. This solution // leaves us with a dead goroutine, but it's better than blocking all // rendering to main views. go utils.Safe(func() { defer close(lineChan) for scanner.Scan() { select { case <-opts.Stop: return case lineChan <- scanner.Bytes(): // We need to confirm the data has been fed into the view before we // pull more from the scanner because the scanner uses the same backing // array and we don't want to be mutating that while it's being written <-lineWrittenChan } } }) loaded := false go utils.Safe(func() { ticker := time.NewTicker(time.Millisecond * 200) defer ticker.Stop() select { case <-opts.Stop: return case <-ticker.C: loadingMutex.Lock() if !loaded { self.beforeStart() _, _ = self.writer.Write([]byte("loading...")) self.refreshView() } loadingMutex.Unlock() } }) go utils.Safe(func() { isViewStale := true writeToView := func(content []byte) { isViewStale = true _, _ = self.writer.Write(content) } refreshViewIfStale := func() { if isViewStale { self.refreshView() isViewStale = false } } outer: for { select { case <-opts.Stop: break outer case linesToRead := <-self.readLines: callThen := func() { if linesToRead.Then != nil { linesToRead.Then() } } for i := 0; linesToRead.Total == -1 || i < linesToRead.Total; i++ { var ok bool var line []byte select { case <-opts.Stop: callThen() break outer case line, ok = <-lineChan: break } loadingMutex.Lock() if !loaded { self.beforeStart() if prefix != "" { writeToView([]byte(prefix)) } loaded = true } loadingMutex.Unlock() if !ok { // if we're here then there's nothing left to scan from the source // so we're at the EOF and can flush the stale content self.onEndOfInput() callThen() break outer } writeToView(append(line, '\n')) lineWrittenChan <- struct{}{} if i+1 == linesToRead.InitialRefreshAfter { // We have read enough lines to fill the view, so do a first refresh // here to show what we have. Continue reading and refresh again at // the end to make sure the scrollbar has the right size. refreshViewIfStale() } } refreshViewIfStale() onFirstPageShown() callThen() } } self.readLines = nil refreshViewIfStale() if err := cmd.Wait(); err != nil { select { case <-opts.Stop: // it's fine if we've killed this program ourselves default: self.Log.Errorf("Unexpected error when running cmd task: %v; Failed command: %v %v", err, cmd.Path, cmd.Args) } } // calling this here again in case the program ended on its own accord onDone() close(done) close(lineWrittenChan) }) self.readLines <- linesToRead <-done return nil } } // Close closes the task manager, killing whatever task may currently be running func (self *ViewBufferManager) Close() { if self.stopCurrentTask == nil { return } c := make(chan struct{}) go utils.Safe(func() { self.stopCurrentTask() c <- struct{}{} }) select { case <-c: return case <-time.After(3 * time.Second): fmt.Println("cannot kill child process") } } // different kinds of tasks: // 1) command based, where the manager can be asked to read more lines, but the command can be killed // 2) string based, where the manager can also be asked to read more lines type TaskOpts struct { // Channel that tells the task to stop, because another task wants to run. Stop chan struct{} // Only for tasks which are long-running, where we read more lines sporadically. // We use this to keep track of when a user's action is complete (i.e. all views // have been refreshed to display the results of their action) InitialContentLoaded func() } func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error { gocuiTask := self.newGocuiTask() var completeTaskOnce sync.Once completeGocuiTask := func() { completeTaskOnce.Do(func() { gocuiTask.Done() }) } go utils.Safe(func() { defer completeGocuiTask() self.taskIDMutex.Lock() self.newTaskID++ taskID := self.newTaskID if self.GetTaskKey() != key && self.onNewKey != nil { self.onNewKey() } self.taskKey = key self.taskIDMutex.Unlock() self.waitingMutex.Lock() self.taskIDMutex.Lock() if taskID < self.newTaskID { self.waitingMutex.Unlock() self.taskIDMutex.Unlock() return } self.taskIDMutex.Unlock() if self.stopCurrentTask != nil { self.stopCurrentTask() } self.readLines = nil stop := make(chan struct{}) notifyStopped := make(chan struct{}) var once sync.Once onStop := func() { close(stop) <-notifyStopped } self.stopCurrentTask = func() { once.Do(onStop) } self.waitingMutex.Unlock() if err := f(TaskOpts{Stop: stop, InitialContentLoaded: completeGocuiTask}); err != nil { self.Log.Error(err) // might need an onError callback } close(notifyStopped) }) return nil } lazygit-0.50.0+ds1/pkg/tasks/tasks_test.go000066400000000000000000000147311500612110400203570ustar00rootroot00000000000000package tasks import ( "bytes" "io" "os/exec" "reflect" "strings" "sync" "testing" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/utils" ) func getCounter() (func(), func() int) { counter := 0 return func() { counter++ }, func() int { return counter } } func TestNewCmdTaskInstantStop(t *testing.T) { writer := bytes.NewBuffer(nil) beforeStart, getBeforeStartCallCount := getCounter() refreshView, getRefreshViewCallCount := getCounter() onEndOfInput, getOnEndOfInputCallCount := getCounter() onNewKey, getOnNewKeyCallCount := getCounter() onDone, getOnDoneCallCount := getCounter() task := gocui.NewFakeTask() newTask := func() gocui.Task { return task } manager := NewViewBufferManager( utils.NewDummyLog(), writer, beforeStart, refreshView, onEndOfInput, onNewKey, newTask, ) stop := make(chan struct{}) reader := bytes.NewBufferString("test") start := func() (*exec.Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") close(stop) return cmd, reader } fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) _ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }}) callCountExpectations := []struct { expected int actual int name string }{ {0, getBeforeStartCallCount(), "beforeStart"}, {1, getRefreshViewCallCount(), "refreshView"}, {0, getOnEndOfInputCallCount(), "onEndOfInput"}, {0, getOnNewKeyCallCount(), "onNewKey"}, {1, getOnDoneCallCount(), "onDone"}, } for _, expectation := range callCountExpectations { if expectation.actual != expectation.expected { t.Errorf("expected %s to be called %d times, got %d", expectation.name, expectation.expected, expectation.actual) } } if task.Status() != gocui.TaskStatusDone { t.Errorf("expected task status to be 'done', got '%s'", task.FormatStatus()) } expectedContent := "" actualContent := writer.String() if actualContent != expectedContent { t.Errorf("expected writer to receive the following content: \n%s\n. But instead it received: %s", expectedContent, actualContent) } } func TestNewCmdTask(t *testing.T) { writer := bytes.NewBuffer(nil) beforeStart, getBeforeStartCallCount := getCounter() refreshView, getRefreshViewCallCount := getCounter() onEndOfInput, getOnEndOfInputCallCount := getCounter() onNewKey, getOnNewKeyCallCount := getCounter() onDone, getOnDoneCallCount := getCounter() task := gocui.NewFakeTask() newTask := func() gocui.Task { return task } manager := NewViewBufferManager( utils.NewDummyLog(), writer, beforeStart, refreshView, onEndOfInput, onNewKey, newTask, ) stop := make(chan struct{}) reader := bytes.NewBufferString("test") start := func() (*exec.Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") return cmd, reader } fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) wg := sync.WaitGroup{} wg.Add(1) go func() { time.Sleep(100 * time.Millisecond) close(stop) wg.Done() }() _ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }}) wg.Wait() callCountExpectations := []struct { expected int actual int name string }{ {1, getBeforeStartCallCount(), "beforeStart"}, {1, getRefreshViewCallCount(), "refreshView"}, {1, getOnEndOfInputCallCount(), "onEndOfInput"}, {0, getOnNewKeyCallCount(), "onNewKey"}, {1, getOnDoneCallCount(), "onDone"}, } for _, expectation := range callCountExpectations { if expectation.actual != expectation.expected { t.Errorf("expected %s to be called %d times, got %d", expectation.name, expectation.expected, expectation.actual) } } if task.Status() != gocui.TaskStatusDone { t.Errorf("expected task status to be 'done', got '%s'", task.FormatStatus()) } expectedContent := "prefix\ntest\n" actualContent := writer.String() if actualContent != expectedContent { t.Errorf("expected writer to receive the following content: \n%s\n. But instead it received: %s", expectedContent, actualContent) } } // A dummy reader that simply yields as many blank lines as requested. The only // thing we want to do with the output is count the number of lines. type BlankLineReader struct { totalLinesToYield int linesYielded int } func (d *BlankLineReader) Read(p []byte) (n int, err error) { if d.totalLinesToYield == d.linesYielded { return 0, io.EOF } d.linesYielded += 1 p[0] = '\n' return 1, nil } func TestNewCmdTaskRefresh(t *testing.T) { type scenario struct { name string totalTaskLines int linesToRead LinesToRead expectedLineCountsOnRefresh []int } scenarios := []scenario{ { "total < initialRefreshAfter", 150, LinesToRead{100, 120, nil}, []int{100}, }, { "total == initialRefreshAfter", 150, LinesToRead{100, 100, nil}, []int{100}, }, { "total > initialRefreshAfter", 150, LinesToRead{100, 50, nil}, []int{50, 100}, }, { "initialRefreshAfter == -1", 150, LinesToRead{100, -1, nil}, []int{100}, }, { "totalTaskLines < initialRefreshAfter", 25, LinesToRead{100, 50, nil}, []int{25}, }, { "totalTaskLines between total and initialRefreshAfter", 75, LinesToRead{100, 50, nil}, []int{50, 75}, }, } for _, s := range scenarios { writer := bytes.NewBuffer(nil) lineCountsOnRefresh := []int{} refreshView := func() { lineCountsOnRefresh = append(lineCountsOnRefresh, strings.Count(writer.String(), "\n")) } task := gocui.NewFakeTask() newTask := func() gocui.Task { return task } manager := NewViewBufferManager( utils.NewDummyLog(), writer, func() {}, refreshView, func() {}, func() {}, newTask, ) stop := make(chan struct{}) reader := BlankLineReader{totalLinesToYield: s.totalTaskLines} start := func() (*exec.Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") return cmd, &reader } fn := manager.NewCmdTask(start, "", s.linesToRead, func() {}) wg := sync.WaitGroup{} wg.Add(1) go func() { time.Sleep(100 * time.Millisecond) close(stop) wg.Done() }() _ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }}) wg.Wait() if !reflect.DeepEqual(lineCountsOnRefresh, s.expectedLineCountsOnRefresh) { t.Errorf("%s: expected line counts on refresh: %v, got %v", s.name, s.expectedLineCountsOnRefresh, lineCountsOnRefresh) } } } lazygit-0.50.0+ds1/pkg/theme/000077500000000000000000000000001500612110400156135ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/theme/gocui.go000066400000000000000000000022541500612110400172530ustar00rootroot00000000000000package theme import ( "github.com/gookit/color" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/utils" ) var gocuiColorMap = map[string]gocui.Attribute{ "default": gocui.ColorDefault, "black": gocui.ColorBlack, "red": gocui.ColorRed, "green": gocui.ColorGreen, "yellow": gocui.ColorYellow, "blue": gocui.ColorBlue, "magenta": gocui.ColorMagenta, "cyan": gocui.ColorCyan, "white": gocui.ColorWhite, "bold": gocui.AttrBold, "reverse": gocui.AttrReverse, "underline": gocui.AttrUnderline, } // GetGocuiAttribute gets the gocui color attribute from the string func GetGocuiAttribute(key string) gocui.Attribute { if utils.IsValidHexValue(key) { values := color.HEX(key).Values() return gocui.NewRGBColor(int32(values[0]), int32(values[1]), int32(values[2])) } value, present := gocuiColorMap[key] if present { return value } return gocui.ColorWhite } // GetGocuiStyle bitwise OR's a list of attributes obtained via the given keys func GetGocuiStyle(keys []string) gocui.Attribute { var attribute gocui.Attribute for _, key := range keys { attribute |= GetGocuiAttribute(key) } return attribute } lazygit-0.50.0+ds1/pkg/theme/style.go000066400000000000000000000015341500612110400173050ustar00rootroot00000000000000package theme import ( "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/utils" ) func GetTextStyle(keys []string, background bool) style.TextStyle { s := style.New() for _, key := range keys { switch key { case "bold": s = s.SetBold() case "reverse": s = s.SetReverse() case "underline": s = s.SetUnderline() case "strikethrough": s = s.SetStrikethrough() default: value, present := style.ColorMap[key] if present { var c style.TextStyle if background { c = value.Background } else { c = value.Foreground } s = s.MergeStyle(c) } else if utils.IsValidHexValue(key) { c := style.NewRGBColor(color.HEX(key, background)) if background { s = s.SetBg(c) } else { s = s.SetFg(c) } } } } return s } lazygit-0.50.0+ds1/pkg/theme/style_test.go000066400000000000000000000025631500612110400203470ustar00rootroot00000000000000package theme import ( "reflect" "testing" "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/gui/style" ) func TestGetTextStyle(t *testing.T) { scenarios := []struct { name string keys []string background bool expected style.TextStyle }{ { name: "empty", keys: []string{""}, background: true, expected: style.New(), }, { name: "named color, fg", keys: []string{"blue"}, background: false, expected: style.New().SetFg(style.NewBasicColor(color.FgBlue)), }, { name: "named color, bg", keys: []string{"blue"}, background: true, expected: style.New().SetBg(style.NewBasicColor(color.BgBlue)), }, { name: "hex color, fg", keys: []string{"#123456"}, background: false, expected: style.New().SetFg(style.NewRGBColor(color.RGBColor{0x12, 0x34, 0x56, 0})), }, { name: "hex color, bg", keys: []string{"#abcdef"}, background: true, expected: style.New().SetBg(style.NewRGBColor(color.RGBColor{0xab, 0xcd, 0xef, 1})), }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { if actual := GetTextStyle(scenario.keys, scenario.background); !reflect.DeepEqual(actual, scenario.expected) { t.Errorf("GetTextStyle() = %v, expected %v", actual, scenario.expected) } }) } } lazygit-0.50.0+ds1/pkg/theme/theme.go000066400000000000000000000064251500612110400172530ustar00rootroot00000000000000package theme import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/style" ) var ( // DefaultTextColor is the default text color DefaultTextColor = style.FgDefault // GocuiDefaultTextColor does the same as DefaultTextColor but this one only colors gocui default text colors GocuiDefaultTextColor = gocui.ColorDefault // ActiveBorderColor is the border color of the active frame ActiveBorderColor gocui.Attribute // InactiveBorderColor is the border color of the inactive active frames InactiveBorderColor gocui.Attribute // FilteredActiveBorderColor is the border color of the active frame, when it's being searched/filtered SearchingActiveBorderColor gocui.Attribute // GocuiSelectedLineBgColor is the background color for the selected line in gocui GocuiSelectedLineBgColor gocui.Attribute // GocuiInactiveViewSelectedLineBgColor is the background color for the selected line in gocui if the view doesn't have focus GocuiInactiveViewSelectedLineBgColor gocui.Attribute OptionsColor gocui.Attribute // SelectedLineBgColor is the background color for the selected line SelectedLineBgColor = style.New() // InactiveViewSelectedLineBgColor is the background color for the selected line if the view doesn't have the focus InactiveViewSelectedLineBgColor = style.New() // CherryPickedCommitColor is the text style when cherry picking a commit CherryPickedCommitTextStyle = style.New() // MarkedBaseCommitTextStyle is the text style of the marked rebase base commit MarkedBaseCommitTextStyle = style.New() OptionsFgColor = style.New() DiffTerminalColor = style.FgMagenta UnstagedChangesColor = style.New() ) // UpdateTheme updates all theme variables func UpdateTheme(themeConfig config.ThemeConfig) { ActiveBorderColor = GetGocuiStyle(themeConfig.ActiveBorderColor) InactiveBorderColor = GetGocuiStyle(themeConfig.InactiveBorderColor) SearchingActiveBorderColor = GetGocuiStyle(themeConfig.SearchingActiveBorderColor) SelectedLineBgColor = GetTextStyle(themeConfig.SelectedLineBgColor, true) InactiveViewSelectedLineBgColor = GetTextStyle(themeConfig.InactiveViewSelectedLineBgColor, true) cherryPickedCommitBgTextStyle := GetTextStyle(themeConfig.CherryPickedCommitBgColor, true) cherryPickedCommitFgTextStyle := GetTextStyle(themeConfig.CherryPickedCommitFgColor, false) CherryPickedCommitTextStyle = cherryPickedCommitBgTextStyle.MergeStyle(cherryPickedCommitFgTextStyle) markedBaseCommitBgTextStyle := GetTextStyle(themeConfig.MarkedBaseCommitBgColor, true) markedBaseCommitFgTextStyle := GetTextStyle(themeConfig.MarkedBaseCommitFgColor, false) MarkedBaseCommitTextStyle = markedBaseCommitBgTextStyle.MergeStyle(markedBaseCommitFgTextStyle) unstagedChangesTextStyle := GetTextStyle(themeConfig.UnstagedChangesColor, false) UnstagedChangesColor = unstagedChangesTextStyle GocuiSelectedLineBgColor = GetGocuiStyle(themeConfig.SelectedLineBgColor) GocuiInactiveViewSelectedLineBgColor = GetGocuiStyle(themeConfig.InactiveViewSelectedLineBgColor) OptionsColor = GetGocuiStyle(themeConfig.OptionsTextColor) OptionsFgColor = GetTextStyle(themeConfig.OptionsTextColor, false) DefaultTextColor = GetTextStyle(themeConfig.DefaultFgColor, false) GocuiDefaultTextColor = GetGocuiStyle(themeConfig.DefaultFgColor) } lazygit-0.50.0+ds1/pkg/updates/000077500000000000000000000000001500612110400161565ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/updates/updates.go000066400000000000000000000200101500612110400201430ustar00rootroot00000000000000package updates import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "time" "github.com/go-errors/errors" "github.com/kardianos/osext" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/utils" ) // Updater checks for updates and does updates type Updater struct { *common.Common Config config.AppConfigurer OSCommand *oscommands.OSCommand } // Updaterer implements the check and update methods type Updaterer interface { CheckForNewUpdate() Update() } // NewUpdater creates a new updater func NewUpdater(cmn *common.Common, config config.AppConfigurer, osCommand *oscommands.OSCommand) (*Updater, error) { return &Updater{ Common: cmn, Config: config, OSCommand: osCommand, }, nil } func (u *Updater) getLatestVersionNumber() (string, error) { req, err := http.NewRequest("GET", constants.Links.RepoUrl+"/releases/latest", nil) if err != nil { return "", err } req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() dec := json.NewDecoder(resp.Body) data := struct { TagName string `json:"tag_name"` }{} if err := dec.Decode(&data); err != nil { return "", err } return data.TagName, nil } // RecordLastUpdateCheck records last time an update check was performed func (u *Updater) RecordLastUpdateCheck() error { u.Config.GetAppState().LastUpdateCheck = time.Now().Unix() return u.Config.SaveAppState() } // expecting version to be of the form `v12.34.56` func (u *Updater) majorVersionDiffers(oldVersion, newVersion string) bool { if oldVersion == "unversioned" { return false } oldVersion = strings.TrimPrefix(oldVersion, "v") newVersion = strings.TrimPrefix(newVersion, "v") return strings.Split(oldVersion, ".")[0] != strings.Split(newVersion, ".")[0] } func (u *Updater) currentVersion() string { versionNumber := u.Config.GetVersion() if versionNumber == "unversioned" { return versionNumber } return fmt.Sprintf("v%s", u.Config.GetVersion()) } func (u *Updater) checkForNewUpdate() (string, error) { u.Log.Info("Checking for an updated version") currentVersion := u.currentVersion() if err := u.RecordLastUpdateCheck(); err != nil { return "", err } newVersion, err := u.getLatestVersionNumber() if err != nil { return "", err } u.Log.Info("Current version is " + currentVersion) u.Log.Info("New version is " + newVersion) if newVersion == currentVersion { return "", errors.New(u.Tr.OnLatestVersionErr) } if u.majorVersionDiffers(currentVersion, newVersion) { errMessage := utils.ResolvePlaceholderString( u.Tr.MajorVersionErr, map[string]string{ "newVersion": newVersion, "currentVersion": currentVersion, }, ) return "", errors.New(errMessage) } rawUrl := u.getBinaryUrl(newVersion) u.Log.Info("Checking for resource at url " + rawUrl) if !u.verifyResourceFound(rawUrl) { errMessage := utils.ResolvePlaceholderString( u.Tr.CouldNotFindBinaryErr, map[string]string{ "url": rawUrl, }, ) return "", errors.New(errMessage) } u.Log.Info("Verified resource is available, ready to update") return newVersion, nil } // CheckForNewUpdate checks if there is an available update func (u *Updater) CheckForNewUpdate(onFinish func(string, error) error, userRequested bool) { if !userRequested && u.skipUpdateCheck() { return } newVersion, err := u.checkForNewUpdate() if err = onFinish(newVersion, err); err != nil { u.Log.Error(err) } } func (u *Updater) skipUpdateCheck() bool { // will remove the check for windows after adding a manifest file asking for // the required permissions if runtime.GOOS == "windows" { u.Log.Info("Updating is currently not supported for windows until we can fix permission issues") return true } if u.Config.GetVersion() == "unversioned" { u.Log.Info("Current version is not built from an official release so we won't check for an update") return true } if u.Config.GetBuildSource() != "buildBinary" { u.Log.Info("Binary is not built with the buildBinary flag so we won't check for an update") return true } userConfig := u.UserConfig() if userConfig.Update.Method == "never" { u.Log.Info("Update method is set to never so we won't check for an update") return true } currentTimestamp := time.Now().Unix() lastUpdateCheck := u.Config.GetAppState().LastUpdateCheck days := userConfig.Update.Days if (currentTimestamp-lastUpdateCheck)/(60*60*24) < days { u.Log.Info("Last update was too recent so we won't check for an update") return true } return false } func (u *Updater) mappedOs(os string) string { osMap := map[string]string{ "darwin": "Darwin", "linux": "Linux", "windows": "Windows", } result, found := osMap[os] if found { return result } return os } func (u *Updater) mappedArch(arch string) string { archMap := map[string]string{ "386": "32-bit", "amd64": "x86_64", } result, found := archMap[arch] if found { return result } return arch } func (u *Updater) zipExtension() string { if runtime.GOOS == "windows" { return "zip" } return "tar.gz" } // example: https://github.com/jesseduffield/lazygit/releases/download/v0.1.73/lazygit_0.1.73_Darwin_x86_64.tar.gz func (u *Updater) getBinaryUrl(newVersion string) string { url := fmt.Sprintf( "%s/releases/download/%s/lazygit_%s_%s_%s.%s", constants.Links.RepoUrl, newVersion, newVersion[1:], u.mappedOs(runtime.GOOS), u.mappedArch(runtime.GOARCH), u.zipExtension(), ) u.Log.Info("Url for latest release is " + url) return url } // Update downloads the latest binary and replaces the current binary with it func (u *Updater) Update(newVersion string) error { return u.update(newVersion) } func (u *Updater) update(newVersion string) error { rawUrl := u.getBinaryUrl(newVersion) u.Log.Info("Updating with url " + rawUrl) return u.downloadAndInstall(rawUrl) } func (u *Updater) downloadAndInstall(rawUrl string) error { configDir := u.Config.GetUserConfigDir() u.Log.Info("Download directory is " + configDir) zipPath := filepath.Join(configDir, "temp_lazygit."+u.zipExtension()) u.Log.Info("Temp path to tarball/zip file is " + zipPath) // remove existing zip file if err := os.RemoveAll(zipPath); err != nil && !os.IsNotExist(err) { return err } // Create the zip file out, err := os.Create(zipPath) if err != nil { return err } defer out.Close() // Get the data resp, err := http.Get(rawUrl) if err != nil { return err } defer resp.Body.Close() // Check server response if resp.StatusCode != http.StatusOK { return fmt.Errorf("error while trying to download latest lazygit: %s", resp.Status) } // Write the body to file _, err = io.Copy(out, resp.Body) if err != nil { return err } u.Log.Info("untarring tarball/unzipping zip file") err = u.OSCommand.Cmd.New([]string{"tar", "-zxf", zipPath, "lazygit"}).Run() if err != nil { return err } // the `tar` terminal cannot store things in a new location without permission // so it creates it in the current directory. As such our path is fairly simple. // You won't see it because it's gitignored. tempLazygitFilePath := "lazygit" u.Log.Infof("Path to temp binary is %s", tempLazygitFilePath) // get the path of the current binary binaryPath, err := osext.Executable() if err != nil { return err } u.Log.Info("Binary path is " + binaryPath) // Verify the main file exists if _, err := os.Stat(zipPath); err != nil { return err } // swap out the old binary for the new one err = os.Rename(tempLazygitFilePath, binaryPath) if err != nil { return err } u.Log.Info("Update complete!") return nil } func (u *Updater) verifyResourceFound(rawUrl string) bool { resp, err := http.Head(rawUrl) if err != nil { return false } defer resp.Body.Close() u.Log.Info("Received status code ", resp.StatusCode) // OK (200) indicates that the resource is present. return resp.StatusCode == http.StatusOK } lazygit-0.50.0+ds1/pkg/utils/000077500000000000000000000000001500612110400156515ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/utils/color.go000066400000000000000000000025661500612110400173270ustar00rootroot00000000000000package utils import ( "regexp" "sync" "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/samber/lo" ) var ( decoloriseCache = make(map[string]string) decoloriseMutex sync.RWMutex ) // Decolorise strips a string of color func Decolorise(str string) string { decoloriseMutex.RLock() val := decoloriseCache[str] decoloriseMutex.RUnlock() if val != "" { return val } re := regexp.MustCompile(`\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[mGK]`) linkRe := regexp.MustCompile(`\x1B]8;[^;]*;(.*?)(\x1B.|\x07)`) ret := re.ReplaceAllString(str, "") ret = linkRe.ReplaceAllString(ret, "") decoloriseMutex.Lock() decoloriseCache[str] = ret decoloriseMutex.Unlock() return ret } func IsValidHexValue(v string) bool { if len(v) != 4 && len(v) != 7 { return false } if v[0] != '#' { return false } for _, char := range v[1:] { switch char { case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F': continue default: return false } } return true } func SetCustomColors(customColors map[string]string) map[string]*style.TextStyle { return lo.MapValues(customColors, func(c string, key string) *style.TextStyle { if s, ok := style.ColorMap[c]; ok { return &s.Foreground } value := style.New().SetFg(style.NewRGBColor(color.HEX(c, false))) return &value }) } lazygit-0.50.0+ds1/pkg/utils/color_test.go000066400000000000000000000072541500612110400203650ustar00rootroot00000000000000package utils import ( "testing" "github.com/jesseduffield/lazygit/pkg/gui/style" ) func TestDecolorise(t *testing.T) { tests := []struct { input string output string }{ { input: "", output: "", }, { input: "hello", output: "hello", }, { input: "hello\x1b[31m", output: "hello", }, { input: "hello\x1b[31mworld", output: "helloworld", }, { input: "hello\x1b[31m\x1b[32mworld", output: "helloworld", }, { input: "hello\x1b[31m\x1b[32m\x1b[33mworld", output: "helloworld", }, { input: "hello\x1b[31m\x1b[32m\x1b[33m\x1b[34mworld", output: "helloworld", }, { input: "hello\x1b[31m\x1b[32m\x1b[33m\x1b[34m\x1b[35mworld", output: "helloworld", }, { input: "hello\x1b[31m\x1b[32m\x1b[33m\x1b[34m\x1b[35m\x1b[36mworld", output: "helloworld", }, { input: "hello\x1b[31m\x1b[32m\x1b[33m\x1b[34m\x1b[35m\x1b[36m\x1b[37mworld", output: "helloworld", }, { input: "hello\x1b[31m\x1b[32m\x1b[33m\x1b[34m\x1b[35m\x1b[36m\x1b[37mworld", output: "helloworld", }, { input: "\x1b[38;2;47;228;2mJD\x1b[0m", output: "JD", }, { input: "\x1b[38;2;160;47;213mRy\x1b[0m", output: "Ry", }, { input: "\x1b[38;2;179;217;72mSB\x1b[0m", output: "SB", }, { input: "\x1b[38;2;48;34;214mMK\x1b[0m", output: "MK", }, { input: "\x1b[38;2;28;152;222mAŁ\x1b[0m", output: "AŁ", }, { input: "\x1b[38;2;237;230;56mHH\x1b[0m", output: "HH", }, { input: "\x1b[38;2;63;232;69mmj\x1b[0m", output: "mj", }, { input: "\x1b[38;2;111;207;16mbl\x1b[0m", output: "bl", }, { input: "\x1b[38;2;250;31;163msa\x1b[0m", output: "sa", }, { input: "\x1b[38;2;195;10;54mbt\x1b[0m", output: "bt", }, { input: "\x1b[38;2;232;147;68mco\x1b[0m", output: "co", }, { input: "\x1b[38;2;116;180;35mDY\x1b[0m", output: "DY", }, { input: "\x1b[38;2;232;1;195mDB\x1b[0m", output: "DB", }, { input: "\x1b[38;2;245;101;55mLi\x1b[0m", output: "Li", }, { input: "\x1b[38;2;47;4;217mRy\x1b[0m", output: "Ry", }, { input: "\x1b[38;2;252;197;1mEl\x1b[0m", output: "El", }, { input: "\x1b[38;2;41;131;237mMG\x1b[0m", output: "MG", }, { input: "\x1b[38;2;65;240;62mDP\x1b[0m", output: "DP", }, { input: "\x1b[38;2;29;201;139mFM\x1b[0m", output: "FM", }, { input: "\x1b[38;2;141;20;198mEB\x1b[0m", output: "EB", }, { input: "\x1b[38;2;60;215;140mDM\x1b[0m", output: "DM", }, { input: "\x1b[38;2;247;63;38mDE\x1b[0m", output: "DE", }, { input: "\x1b[38;2;67;210;17mCB\x1b[0m", output: "CB", }, { input: "\x1b[38;2;220;190;84mST\x1b[0m", output: "ST", }, { input: "\x1b[38;2;137;239;6mER\x1b[0m", output: "ER", }, { input: "\x1b[38;2;47;249;225mAY\x1b[0m", output: "AY", }, { input: "\x1b[38;2;215;16;195mca\x1b[0m", output: "ca", }, { input: "\x1b[38;2;73;215;122mRV\x1b[0m", output: "RV", }, { input: "\x1b[38;2;118;15;221mJP\x1b[0m", output: "JP", }, { input: "\x1b[38;2;186;163;39mHJ\x1b[0m", output: "HJ", }, { input: "\x1b[38;2;54;222;111mDD\x1b[0m", output: "DD", }, { input: "\x1b[38;2;56;209;108mPZ\x1b[0m", output: "PZ", }, { input: "\x1b[38;2;9;179;216mPM\x1b[0m", output: "PM", }, { input: "\x1b[38;2;157;205;18mta\x1b[0m", output: "ta", }, { input: "a_" + style.PrintSimpleHyperlink("xyz") + "_b", output: "a_xyz_b", }, } for _, test := range tests { output := Decolorise(test.input) if output != test.output { t.Errorf("Decolorise(%s) = %s, want %s", test.input, output, test.output) } } } lazygit-0.50.0+ds1/pkg/utils/date.go000066400000000000000000000026661500612110400171270ustar00rootroot00000000000000package utils import ( "fmt" "time" ) func UnixToTimeAgo(timestamp int64) string { now := time.Now().Unix() return formatSecondsAgo(now - timestamp) } const ( SECONDS_IN_SECOND = 1 SECONDS_IN_MINUTE = 60 SECONDS_IN_HOUR = 3600 SECONDS_IN_DAY = 86400 SECONDS_IN_WEEK = 604800 SECONDS_IN_YEAR = 31536000 SECONDS_IN_MONTH = SECONDS_IN_YEAR / 12 ) type period struct { label string secondsInPeriod int64 } var periods = []period{ {"s", SECONDS_IN_SECOND}, {"m", SECONDS_IN_MINUTE}, {"h", SECONDS_IN_HOUR}, {"d", SECONDS_IN_DAY}, {"w", SECONDS_IN_WEEK}, {"M", SECONDS_IN_MONTH}, {"y", SECONDS_IN_YEAR}, } func formatSecondsAgo(secondsAgo int64) string { for i, period := range periods { if i == 0 { continue } if secondsAgo < period.secondsInPeriod { return fmt.Sprintf("%d%s", secondsAgo/periods[i-1].secondsInPeriod, periods[i-1].label, ) } } return fmt.Sprintf("%d%s", secondsAgo/periods[len(periods)-1].secondsInPeriod, periods[len(periods)-1].label, ) } // formats the date in a smart way, if the date is today, it will show the time, otherwise it will show the date func UnixToDateSmart(now time.Time, timestamp int64, longTimeFormat string, shortTimeFormat string) string { date := time.Unix(timestamp, 0) if date.Day() == now.Day() && date.Month() == now.Month() && date.Year() == now.Year() { return date.Format(shortTimeFormat) } return date.Format(longTimeFormat) } lazygit-0.50.0+ds1/pkg/utils/date_test.go000066400000000000000000000025151500612110400201570ustar00rootroot00000000000000package utils import ( "testing" ) func TestFormatSecondsAgo(t *testing.T) { tests := []struct { name string args int64 want string }{ { name: "zero", args: 0, want: "0s", }, { name: "one second", args: 1, want: "1s", }, { name: "almost a minute", args: 59, want: "59s", }, { name: "one minute", args: 60, want: "1m", }, { name: "one minute and one second", args: 61, want: "1m", }, { name: "almost one hour", args: 3599, want: "59m", }, { name: "one hour", args: 3600, want: "1h", }, { name: "almost one day", args: 86399, want: "23h", }, { name: "one day", args: 86400, want: "1d", }, { name: "almost a week", args: 604799, want: "6d", }, { name: "one week", args: 604800, want: "1w", }, { name: "six months", args: SECONDS_IN_YEAR / 2, want: "6M", }, { name: "almost one year", args: 31535999, want: "11M", }, { name: "one year", args: SECONDS_IN_YEAR, want: "1y", }, { name: "50 years", args: SECONDS_IN_YEAR * 50, want: "50y", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := formatSecondsAgo(tt.args); got != tt.want { t.Errorf("formatSecondsAgo(%d) = %v, want %v", tt.args, got, tt.want) } }) } } lazygit-0.50.0+ds1/pkg/utils/dummies.go000066400000000000000000000020241500612110400176410ustar00rootroot00000000000000package utils import ( "io" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/sirupsen/logrus" "github.com/spf13/afero" ) // NewDummyLog creates a new dummy Log for testing func NewDummyLog() *logrus.Entry { log := logrus.New() log.Out = io.Discard return log.WithField("test", "test") } func NewDummyCommon() *common.Common { tr := i18n.EnglishTranslationSet() cmn := &common.Common{ Log: NewDummyLog(), Tr: tr, Fs: afero.NewOsFs(), } cmn.SetUserConfig(config.GetDefaultConfig()) return cmn } func NewDummyCommonWithUserConfigAndAppState(userConfig *config.UserConfig, appState *config.AppState) *common.Common { tr := i18n.EnglishTranslationSet() cmn := &common.Common{ Log: NewDummyLog(), Tr: tr, AppState: appState, // TODO: remove dependency on actual filesystem in tests and switch to using // in-memory for everything Fs: afero.NewOsFs(), } cmn.SetUserConfig(userConfig) return cmn } lazygit-0.50.0+ds1/pkg/utils/errors.go000066400000000000000000000005521500612110400175160ustar00rootroot00000000000000package utils import "github.com/go-errors/errors" // WrapError wraps an error for the sake of showing a stack trace at the top level // the go-errors package, for some reason, does not return nil when you try to wrap // a non-error, so we're just doing it here func WrapError(err error) error { if err == nil { return err } return errors.Wrap(err, 0) } lazygit-0.50.0+ds1/pkg/utils/formatting.go000066400000000000000000000130701500612110400203530ustar00rootroot00000000000000package utils import ( "fmt" "strings" "unicode" "github.com/mattn/go-runewidth" "github.com/samber/lo" "golang.org/x/exp/slices" ) type Alignment int const ( AlignLeft Alignment = iota AlignRight ) type ColumnConfig struct { Width int Alignment Alignment } func StringWidth(s string) int { // We are intentionally not using a range loop here, because that would // convert the characters to runes, which is unnecessary work in this case. for i := 0; i < len(s); i++ { if s[i] > unicode.MaxASCII { return runewidth.StringWidth(s) } } return len(s) } // WithPadding pads a string as much as you want func WithPadding(str string, padding int, alignment Alignment) string { uncoloredStr := Decolorise(str) width := StringWidth(uncoloredStr) if padding < width { return str } space := strings.Repeat(" ", padding-width) if alignment == AlignLeft { return str + space } else { return space + str } } // defaults to left-aligning each column. If you want to set the alignment of // each column, pass in a slice of Alignment values. // returns a list of strings that should be joined with "\n", and an array of // the column positions func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Alignment) ([]string, []int) { if len(displayStringsArr) == 0 { return []string{}, nil } displayStringsArr, columnAlignments, removedColumns := excludeBlankColumns(displayStringsArr, columnAlignments) padWidths := getPadWidths(displayStringsArr) columnConfigs := make([]ColumnConfig, len(padWidths)) columnPositions := make([]int, len(padWidths)+1) columnPositions[0] = 0 for i, padWidth := range padWidths { // gracefully handle when columnAlignments is shorter than padWidths alignment := AlignLeft if len(columnAlignments) > i { alignment = columnAlignments[i] } columnConfigs[i] = ColumnConfig{ Width: padWidth, Alignment: alignment, } columnPositions[i+1] = columnPositions[i] + padWidth + 1 } // Add the removed columns back into columnPositions (a removed column gets // the same position as the following column); clients should be able to rely // on them all to be there for _, removedColumn := range removedColumns { if removedColumn < len(columnPositions) { columnPositions = slices.Insert(columnPositions, removedColumn, columnPositions[removedColumn]) } } return getPaddedDisplayStrings(displayStringsArr, columnConfigs), columnPositions } // NOTE: this mutates the input slice for the sake of performance func excludeBlankColumns(displayStringsArr [][]string, columnAlignments []Alignment) ([][]string, []Alignment, []int) { if len(displayStringsArr) == 0 { return displayStringsArr, columnAlignments, []int{} } // if all rows share a blank column, we want to remove that column toRemove := []int{} outer: for i := range displayStringsArr[0] { for _, strings := range displayStringsArr { if strings[i] != "" { continue outer } } toRemove = append(toRemove, i) } if len(toRemove) == 0 { return displayStringsArr, columnAlignments, []int{} } // remove the columns for i, strings := range displayStringsArr { for j := len(toRemove) - 1; j >= 0; j-- { strings = slices.Delete(strings, toRemove[j], toRemove[j]+1) } displayStringsArr[i] = strings } for j := len(toRemove) - 1; j >= 0; j-- { if columnAlignments != nil && toRemove[j] < len(columnAlignments) { columnAlignments = slices.Delete(columnAlignments, toRemove[j], toRemove[j]+1) } } return displayStringsArr, columnAlignments, toRemove } func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConfig) []string { result := make([]string, 0, len(stringArrays)) for _, stringArray := range stringArrays { if len(stringArray) == 0 { continue } builder := strings.Builder{} for j, columnConfig := range columnConfigs { if len(stringArray)-1 < j { continue } builder.WriteString(WithPadding(stringArray[j], columnConfig.Width, columnConfig.Alignment)) builder.WriteString(" ") } if len(stringArray)-1 < len(columnConfigs) { continue } builder.WriteString(stringArray[len(columnConfigs)]) result = append(result, builder.String()) } return result } func getPadWidths(stringArrays [][]string) []int { maxWidth := MaxFn(stringArrays, func(stringArray []string) int { return len(stringArray) }) if maxWidth-1 < 0 { return []int{} } return lo.Map(lo.Range(maxWidth-1), func(i int, _ int) int { return MaxFn(stringArrays, func(stringArray []string) int { uncoloredStr := Decolorise(stringArray[i]) return StringWidth(uncoloredStr) }) }) } func MaxFn[T any](items []T, fn func(T) int) int { max := 0 for _, item := range items { if fn(item) > max { max = fn(item) } } return max } // TruncateWithEllipsis returns a string, truncated to a certain length, with an ellipsis func TruncateWithEllipsis(str string, limit int) string { if StringWidth(str) > limit && limit <= 2 { return strings.Repeat(".", limit) } return runewidth.Truncate(str, limit, "…") } func SafeTruncate(str string, limit int) string { if len(str) > limit { return str[0:limit] } else { return str } } const COMMIT_HASH_SHORT_SIZE = 8 func ShortHash(hash string) string { if len(hash) < COMMIT_HASH_SHORT_SIZE { return hash } return hash[:COMMIT_HASH_SHORT_SIZE] } // Returns comma-separated list of paths, with ellipsis if there are more than 3 // e.g. "foo, bar, baz, [...3 more]" func FormatPaths(paths []string) string { if len(paths) <= 3 { return strings.Join(paths, ", ") } return fmt.Sprintf("%s, %s, %s, [...%d more]", paths[0], paths[1], paths[2], len(paths)-3) } lazygit-0.50.0+ds1/pkg/utils/formatting_test.go000066400000000000000000000136661500612110400214250ustar00rootroot00000000000000package utils import ( "strings" "testing" "github.com/mattn/go-runewidth" "github.com/stretchr/testify/assert" ) func TestWithPadding(t *testing.T) { type scenario struct { str string padding int alignment Alignment expected string } scenarios := []scenario{ { str: "hello world !", padding: 1, alignment: AlignLeft, expected: "hello world !", }, { str: "hello world !", padding: 14, alignment: AlignLeft, expected: "hello world ! ", }, { str: "hello world !", padding: 14, alignment: AlignRight, expected: " hello world !", }, { str: "Güçlü", padding: 7, alignment: AlignLeft, expected: "Güçlü ", }, { str: "Güçlü", padding: 7, alignment: AlignRight, expected: " Güçlü", }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, WithPadding(s.str, s.padding, s.alignment)) } } func TestGetPadWidths(t *testing.T) { type scenario struct { input [][]string expected []int } tests := []scenario{ { [][]string{{""}, {""}}, []int{}, }, { [][]string{{"a"}, {""}}, []int{}, }, { [][]string{{"aa", "b", "ccc"}, {"c", "d", "e"}}, []int{2, 1}, }, { [][]string{{"AŁ", "b", "ccc"}, {"c", "d", "e"}}, []int{2, 1}, }, } for _, test := range tests { output := getPadWidths(test.input) assert.EqualValues(t, test.expected, output) } } func TestTruncateWithEllipsis(t *testing.T) { // will need to check chinese characters as well // important that we have a three dot ellipsis within the limit type scenario struct { str string limit int expected string } scenarios := []scenario{ { "hello world !", 1, ".", }, { "hello world !", 2, "..", }, { "hello world !", 3, "he…", }, { "hello world !", 4, "hel…", }, { "hello world !", 5, "hell…", }, { "hello world !", 12, "hello world…", }, { "hello world !", 13, "hello world !", }, { "hello world !", 14, "hello world !", }, { "大大大大", 5, "大大…", }, { "大大大大", 2, "..", }, { "大大大大", 1, ".", }, { "大大大大", 0, "", }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, TruncateWithEllipsis(s.str, s.limit)) } } func TestRenderDisplayStrings(t *testing.T) { type scenario struct { input [][]string columnAlignments []Alignment expectedOutput string expectedColumnPositions []int } tests := []scenario{ { input: [][]string{{""}, {""}}, columnAlignments: nil, expectedOutput: "", expectedColumnPositions: []int{0, 0}, }, { input: [][]string{{"a"}, {""}}, columnAlignments: nil, expectedOutput: "a\n", expectedColumnPositions: []int{0}, }, { input: [][]string{{"a"}, {"b"}}, columnAlignments: nil, expectedOutput: "a\nb", expectedColumnPositions: []int{0}, }, { input: [][]string{{"a", "b"}, {"c", "d"}}, columnAlignments: nil, expectedOutput: "a b\nc d", expectedColumnPositions: []int{0, 2}, }, { input: [][]string{{"a", "", "c"}, {"d", "", "f"}}, columnAlignments: nil, expectedOutput: "a c\nd f", expectedColumnPositions: []int{0, 2, 2}, }, { input: [][]string{{"a", "", "c", ""}, {"d", "", "f", ""}}, columnAlignments: nil, expectedOutput: "a c\nd f", expectedColumnPositions: []int{0, 2, 2}, }, { input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}}, columnAlignments: nil, expectedOutput: "abc d\ne f", expectedColumnPositions: []int{0, 4, 4}, }, { input: [][]string{{"", "abc", "", "", "d", "e"}, {"", "f", "", "", "g", "h"}}, columnAlignments: nil, expectedOutput: "abc d e\nf g h", expectedColumnPositions: []int{0, 0, 4, 4, 4, 6}, }, { input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}}, columnAlignments: []Alignment{AlignLeft, AlignLeft}, // same as nil (default) expectedOutput: "abc d\ne f", expectedColumnPositions: []int{0, 4, 4}, }, { input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}}, columnAlignments: []Alignment{AlignRight, AlignLeft}, expectedOutput: "abc d\n e f", expectedColumnPositions: []int{0, 4, 4}, }, { input: [][]string{{"a", "", "bcd", "efg", "h"}, {"i", "", "j", "k", "l"}}, columnAlignments: []Alignment{AlignLeft, AlignLeft, AlignRight, AlignLeft}, expectedOutput: "a bcd efg h\ni j k l", expectedColumnPositions: []int{0, 2, 2, 6, 10}, }, { input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}}, columnAlignments: []Alignment{AlignRight}, // gracefully defaults unspecified columns to left-align expectedOutput: "abc d\n e f", expectedColumnPositions: []int{0, 4, 4}, }, } for _, test := range tests { output, columnPositions := RenderDisplayStrings(test.input, test.columnAlignments) assert.EqualValues(t, test.expectedOutput, strings.Join(output, "\n")) assert.EqualValues(t, test.expectedColumnPositions, columnPositions) } } func BenchmarkStringWidthAsciiOriginal(b *testing.B) { for b.Loop() { runewidth.StringWidth("some ASCII string") } } func BenchmarkStringWidthAsciiOptimized(b *testing.B) { for b.Loop() { StringWidth("some ASCII string") } } func BenchmarkStringWidthNonAsciiOriginal(b *testing.B) { for b.Loop() { runewidth.StringWidth("some non-ASCII string 🍉") } } func BenchmarkStringWidthNonAsciiOptimized(b *testing.B) { for b.Loop() { StringWidth("some non-ASCII string 🍉") } } lazygit-0.50.0+ds1/pkg/utils/history_buffer.go000066400000000000000000000013551500612110400212360ustar00rootroot00000000000000package utils import ( "errors" ) type HistoryBuffer[T any] struct { maxSize int items []T } func NewHistoryBuffer[T any](maxSize int) *HistoryBuffer[T] { return &HistoryBuffer[T]{ maxSize: maxSize, items: make([]T, 0, maxSize), } } func (self *HistoryBuffer[T]) Push(item T) { if len(self.items) == self.maxSize { self.items = self.items[:len(self.items)-1] } self.items = append([]T{item}, self.items...) } func (self *HistoryBuffer[T]) PeekAt(index int) (T, error) { var item T if len(self.items) == 0 { return item, errors.New("Buffer is empty") } if len(self.items) <= index || index < -1 { return item, errors.New("Index out of range") } if index == -1 { return item, nil } return self.items[index], nil } lazygit-0.50.0+ds1/pkg/utils/history_buffer_test.go000066400000000000000000000022651500612110400222760ustar00rootroot00000000000000package utils import ( "testing" "github.com/stretchr/testify/assert" ) func TestNewHistoryBuffer(t *testing.T) { hb := NewHistoryBuffer[int](5) assert.NotNil(t, hb) assert.Equal(t, 5, hb.maxSize) assert.Equal(t, 0, len(hb.items)) } func TestPush(t *testing.T) { hb := NewHistoryBuffer[int](3) hb.Push(1) hb.Push(2) hb.Push(3) hb.Push(4) assert.Equal(t, 3, len(hb.items)) assert.Equal(t, []int{4, 3, 2}, hb.items) } func TestPeekAt(t *testing.T) { hb := NewHistoryBuffer[int](3) hb.Push(1) hb.Push(2) hb.Push(3) item, err := hb.PeekAt(0) assert.Nil(t, err) assert.Equal(t, 3, item) item, err = hb.PeekAt(1) assert.Nil(t, err) assert.Equal(t, 2, item) item, err = hb.PeekAt(2) assert.Nil(t, err) assert.Equal(t, 1, item) item, err = hb.PeekAt(-1) assert.Nil(t, err) assert.Equal(t, 0, item) _, err = hb.PeekAt(3) assert.NotNil(t, err) assert.Equal(t, "Index out of range", err.Error()) _, err = hb.PeekAt(-2) assert.NotNil(t, err) assert.Equal(t, "Index out of range", err.Error()) } func TestPeekAtEmptyBuffer(t *testing.T) { hb := NewHistoryBuffer[int](3) _, err := hb.PeekAt(0) assert.NotNil(t, err) assert.Equal(t, "Buffer is empty", err.Error()) } lazygit-0.50.0+ds1/pkg/utils/io.go000066400000000000000000000007311500612110400166100ustar00rootroot00000000000000package utils import ( "bufio" "io" "os" ) func ForEachLineInFile(path string, f func(string, int)) error { file, err := os.Open(path) if err != nil { return err } defer file.Close() forEachLineInStream(file, f) return nil } func forEachLineInStream(reader io.Reader, f func(string, int)) { bufferedReader := bufio.NewReader(reader) for i := 0; true; i++ { line, _ := bufferedReader.ReadString('\n') if len(line) == 0 { break } f(line, i) } } lazygit-0.50.0+ds1/pkg/utils/io_test.go000066400000000000000000000023611500612110400176500ustar00rootroot00000000000000package utils import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func Test_forEachLineInStream(t *testing.T) { scenarios := []struct { name string input string expectedLines []string }{ { name: "empty input", input: "", expectedLines: []string{}, }, { name: "single line", input: "abc\n", expectedLines: []string{"abc\n"}, }, { name: "single line without line feed", input: "abc", expectedLines: []string{"abc"}, }, { name: "multiple lines", input: "abc\ndef\n", expectedLines: []string{"abc\n", "def\n"}, }, { name: "multiple lines including empty lines", input: "abc\n\ndef\n", expectedLines: []string{"abc\n", "\n", "def\n"}, }, { name: "multiple lines without linefeed at end of file", input: "abc\ndef\nghi", expectedLines: []string{"abc\n", "def\n", "ghi"}, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { lines := []string{} forEachLineInStream(strings.NewReader(s.input), func(line string, i int) { lines = append(lines, line) }) assert.EqualValues(t, s.expectedLines, lines) }) } } lazygit-0.50.0+ds1/pkg/utils/lines.go000066400000000000000000000120701500612110400173120ustar00rootroot00000000000000package utils import ( "bytes" "strings" "github.com/mattn/go-runewidth" ) // SplitLines takes a multiline string and splits it on newlines // currently we are also stripping \r's which may have adverse effects for // windows users (but no issues have been raised yet) func SplitLines(multilineString string) []string { multilineString = strings.Replace(multilineString, "\r", "", -1) if multilineString == "" || multilineString == "\n" { return make([]string, 0) } lines := strings.Split(multilineString, "\n") if lines[len(lines)-1] == "" { return lines[:len(lines)-1] } return lines } func SplitNul(str string) []string { if str == "" { return make([]string, 0) } str = strings.TrimSuffix(str, "\x00") return strings.Split(str, "\x00") } // NormalizeLinefeeds - Removes all Windows and Mac style line feeds func NormalizeLinefeeds(str string) string { str = strings.Replace(str, "\r\n", "\n", -1) str = strings.Replace(str, "\r", "", -1) return str } // EscapeSpecialChars - Replaces all special chars like \n with \\n func EscapeSpecialChars(str string) string { return strings.NewReplacer( "\n", "\\n", "\r", "\\r", "\t", "\\t", "\b", "\\b", "\f", "\\f", "\v", "\\v", ).Replace(str) } func dropCR(data []byte) []byte { if len(data) > 0 && data[len(data)-1] == '\r' { return data[0 : len(data)-1] } return data } // ScanLinesAndTruncateWhenLongerThanBuffer returns a split function that can be // used with bufio.Scanner.Split(). It is very similar to bufio.ScanLines, // except that it will truncate lines that are longer than the scanner's read // buffer (whereas bufio.ScanLines will return an error in that case, which is // often difficult to handle). // // If you are using your own buffer for the scanner, you must set maxBufferSize // to the same value as the max parameter that you passed to scanner.Buffer(). // Otherwise, maxBufferSize must be set to bufio.MaxScanTokenSize. func ScanLinesAndTruncateWhenLongerThanBuffer(maxBufferSize int) func(data []byte, atEOF bool) (int, []byte, error) { skipOverRemainderOfLongLine := false return func(data []byte, atEOF bool) (int, []byte, error) { if atEOF && len(data) == 0 { // Done return 0, nil, nil } if i := bytes.IndexByte(data, '\n'); i >= 0 { if skipOverRemainderOfLongLine { skipOverRemainderOfLongLine = false return i + 1, nil, nil } return i + 1, dropCR(data[0:i]), nil } if atEOF { if skipOverRemainderOfLongLine { return len(data), nil, nil } return len(data), dropCR(data), nil } // Buffer is full, so we can't get more data if len(data) >= maxBufferSize { if skipOverRemainderOfLongLine { return len(data), nil, nil } skipOverRemainderOfLongLine = true return len(data), data, nil } // Request more data. return 0, nil, nil } } // Wrap lines to a given width, and return: // - the wrapped lines // - the line indices of the wrapped lines, indexed by the original line indices // - the line indices of the original lines, indexed by the wrapped line indices // If wrap is false, the text is returned as is. // This code needs to behave the same as `gocui.lineWrap` does. func WrapViewLinesToWidth(wrap bool, editable bool, text string, width int, tabWidth int) ([]string, []int, []int) { if !editable { text = strings.TrimSuffix(text, "\n") } lines := strings.Split(text, "\n") if !wrap { indices := make([]int, len(lines)) for i := range lines { indices[i] = i } return lines, indices, indices } wrappedLines := make([]string, 0, len(lines)) wrappedLineIndices := make([]int, 0, len(lines)) originalLineIndices := make([]int, 0, len(lines)) if tabWidth < 1 { tabWidth = 4 } for originalLineIdx, line := range lines { wrappedLineIndices = append(wrappedLineIndices, len(wrappedLines)) // convert tabs to spaces for i := 0; i < len(line); i++ { if line[i] == '\t' { numSpaces := tabWidth - (i % tabWidth) line = line[:i] + strings.Repeat(" ", numSpaces) + line[i+1:] i += numSpaces - 1 } } appendWrappedLine := func(str string) { wrappedLines = append(wrappedLines, str) originalLineIndices = append(originalLineIndices, originalLineIdx) } n := 0 offset := 0 lastWhitespaceIndex := -1 for i, currChr := range line { rw := runewidth.RuneWidth(currChr) n += rw if n > width { if currChr == ' ' { appendWrappedLine(line[offset:i]) offset = i + 1 n = 0 } else if currChr == '-' { appendWrappedLine(line[offset:i]) offset = i n = rw } else if lastWhitespaceIndex != -1 { if line[lastWhitespaceIndex] == '-' { appendWrappedLine(line[offset : lastWhitespaceIndex+1]) } else { appendWrappedLine(line[offset:lastWhitespaceIndex]) } offset = lastWhitespaceIndex + 1 n = runewidth.StringWidth(line[offset : i+1]) } else { appendWrappedLine(line[offset:i]) offset = i n = rw } lastWhitespaceIndex = -1 } else if currChr == ' ' || currChr == '-' { lastWhitespaceIndex = i } } appendWrappedLine(line[offset:]) } return wrappedLines, wrappedLineIndices, originalLineIndices } lazygit-0.50.0+ds1/pkg/utils/lines_test.go000066400000000000000000000230231500612110400203510ustar00rootroot00000000000000package utils import ( "bufio" "strings" "testing" "github.com/jesseduffield/gocui" "github.com/stretchr/testify/assert" ) // TestSplitLines is a function. func TestSplitLines(t *testing.T) { type scenario struct { multilineString string expected []string } scenarios := []scenario{ { "", []string{}, }, { "\n", []string{}, }, { "hello world !\nhello universe !\n", []string{ "hello world !", "hello universe !", }, }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, SplitLines(s.multilineString)) } } func TestSplitNul(t *testing.T) { type scenario struct { multilineString string expected []string } scenarios := []scenario{ { "", []string{}, }, { "\x00", []string{ "", }, }, { "hello world !\x00hello universe !\x00", []string{ "hello world !", "hello universe !", }, }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, SplitNul(s.multilineString)) } } // TestNormalizeLinefeeds is a function. func TestNormalizeLinefeeds(t *testing.T) { type scenario struct { byteArray []byte expected []byte } scenarios := []scenario{ { // \r\n []byte{97, 115, 100, 102, 13, 10}, []byte{97, 115, 100, 102, 10}, }, { // bash\r\nblah []byte{97, 115, 100, 102, 13, 10, 97, 115, 100, 102}, []byte{97, 115, 100, 102, 10, 97, 115, 100, 102}, }, { // \r []byte{97, 115, 100, 102, 13}, []byte{97, 115, 100, 102}, }, { // \n []byte{97, 115, 100, 102, 10}, []byte{97, 115, 100, 102, 10}, }, } for _, s := range scenarios { assert.EqualValues(t, string(s.expected), NormalizeLinefeeds(string(s.byteArray))) } } func TestScanLinesAndTruncateWhenLongerThanBuffer(t *testing.T) { type scenario struct { input string expectedLines []string } scenarios := []scenario{ { "", []string{}, }, { "\n", []string{""}, }, { "abc", []string{"abc"}, }, { "abc\ndef", []string{"abc", "def"}, }, { "abc\n\ndef", []string{"abc", "", "def"}, }, { "abc\r\ndef\r", []string{"abc", "def"}, }, { "abcdef", []string{"abcde"}, }, { "abcdef\n", []string{"abcde"}, }, { "abcdef\nghijkl\nx", []string{"abcde", "ghijk", "x"}, }, { "abc\ndefghijklmnopqrstuvw\nx", []string{"abc", "defgh", "x"}, }, } for _, s := range scenarios { scanner := bufio.NewScanner(strings.NewReader(s.input)) scanner.Buffer(make([]byte, 5), 5) scanner.Split(ScanLinesAndTruncateWhenLongerThanBuffer(5)) result := []string{} for scanner.Scan() { result = append(result, scanner.Text()) } assert.NoError(t, scanner.Err()) assert.EqualValues(t, s.expectedLines, result) } } func TestWrapViewLinesToWidth(t *testing.T) { tests := []struct { name string wrap bool editable bool text string width int tabWidth int expectedWrappedLines []string expectedWrappedLinesIndices []int expectedOriginalLinesIndices []int }{ { name: "Wrap off", wrap: false, text: "1st line\n2nd line\n3rd line", width: 5, expectedWrappedLines: []string{ "1st line", "2nd line", "3rd line", }, expectedWrappedLinesIndices: []int{0, 1, 2}, expectedOriginalLinesIndices: []int{0, 1, 2}, }, { name: "Wrap on space", wrap: true, text: "Hello World", width: 5, expectedWrappedLines: []string{ "Hello", "World", }, expectedWrappedLinesIndices: []int{0}, expectedOriginalLinesIndices: []int{0, 0}, }, { name: "Wrap on hyphen", wrap: true, text: "Hello-World", width: 6, expectedWrappedLines: []string{ "Hello-", "World", }, }, { name: "Wrap on hyphen 2", wrap: true, text: "Blah Hello-World", width: 12, expectedWrappedLines: []string{ "Blah Hello-", "World", }, }, { name: "Wrap on hyphen 3", wrap: true, text: "Blah Hello-World", width: 11, expectedWrappedLines: []string{ "Blah Hello-", "World", }, }, { name: "Wrap on hyphen 4", wrap: true, text: "Blah Hello-World", width: 10, expectedWrappedLines: []string{ "Blah Hello", "-World", }, }, { name: "Wrap on space 2", wrap: true, text: "Blah Hello World", width: 10, expectedWrappedLines: []string{ "Blah Hello", "World", }, }, { name: "Wrap on space with more words", wrap: true, text: "Longer word here", width: 10, expectedWrappedLines: []string{ "Longer", "word here", }, }, { name: "Split word that's too long", wrap: true, text: "ThisWordIsWayTooLong", width: 10, expectedWrappedLines: []string{ "ThisWordIs", "WayTooLong", }, }, { name: "Split word that's too long over multiple lines", wrap: true, text: "ThisWordIsWayTooLong", width: 5, expectedWrappedLines: []string{ "ThisW", "ordIs", "WayTo", "oLong", }, }, { name: "Lots of hyphens", wrap: true, text: "one-two-three-four-five", width: 8, expectedWrappedLines: []string{ "one-two-", "three-", "four-", "five", }, }, { name: "Several lines using all the available width", wrap: true, text: "aaa bb cc ddd-ee ff", width: 5, expectedWrappedLines: []string{ "aaa", "bb cc", "ddd-", "ee ff", }, }, { name: "Several lines using all the available width, with multi-cell runes", wrap: true, text: "🐤🐤🐤 🐝🐝 🙉🙉 🦊🦊🦊-🐬🐬 🦢🦢", width: 9, expectedWrappedLines: []string{ "🐤🐤🐤", "🐝🐝 🙉🙉", "🦊🦊🦊-", "🐬🐬 🦢🦢", }, }, { name: "Space in last column", wrap: true, text: "hello world", width: 6, expectedWrappedLines: []string{ "hello", "world", }, }, { name: "Hyphen in last column", wrap: true, text: "hello-world", width: 6, expectedWrappedLines: []string{ "hello-", "world", }, }, { name: "English text", wrap: true, text: "+The sea reach of the Thames stretched before us like the bedinnind of an interminable waterway. In the offind the sea and the sky were welded todether without a joint, and in the luminous space the tanned sails of the bardes drifting blah blah", width: 81, expectedWrappedLines: []string{ "+The sea reach of the Thames stretched before us like the bedinnind of an", "interminable waterway. In the offind the sea and the sky were welded todether", "without a joint, and in the luminous space the tanned sails of the bardes", "drifting blah blah", }, }, { name: "Tabs, width 4", wrap: true, text: "\ta\tbb\tccc\tdddd\teeeee", width: 50, tabWidth: 4, expectedWrappedLines: []string{ " a bb ccc dddd eeeee", }, }, { name: "Tabs, width 8", wrap: true, text: "\ta\tbb\tccc\tdddddddd\teeeee", width: 100, tabWidth: 8, expectedWrappedLines: []string{ " a bb ccc dddddddd eeeee", }, }, { name: "Multiple lines", wrap: true, text: "First paragraph\nThe second paragraph is a bit longer.\nThird paragraph\n", width: 10, expectedWrappedLines: []string{ "First", "paragraph", "The second", "paragraph", "is a bit", "longer.", "Third", "paragraph", }, expectedWrappedLinesIndices: []int{0, 2, 6}, expectedOriginalLinesIndices: []int{0, 0, 1, 1, 1, 1, 2, 2}, }, { name: "Avoid blank line at end if not editable", wrap: true, editable: false, text: "First\nSecond\nThird\n", width: 10, expectedWrappedLines: []string{ "First", "Second", "Third", }, expectedWrappedLinesIndices: []int{0, 1, 2}, expectedOriginalLinesIndices: []int{0, 1, 2}, }, { name: "Avoid blank line at end if not editable", wrap: true, editable: false, text: "First\nSecond\nThird\n", width: 10, expectedWrappedLines: []string{ "First", "Second", "Third", }, expectedWrappedLinesIndices: []int{0, 1, 2}, expectedOriginalLinesIndices: []int{0, 1, 2}, }, { name: "Keep blank line at end if editable", wrap: true, editable: true, text: "First\nSecond\nThird\n", width: 10, expectedWrappedLines: []string{ "First", "Second", "Third", "", }, expectedWrappedLinesIndices: []int{0, 1, 2, 3}, expectedOriginalLinesIndices: []int{0, 1, 2, 3}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tabWidth := tt.tabWidth if tabWidth == 0 { tabWidth = 4 } wrappedLines, wrappedLinesIndices, originalLinesIndices := WrapViewLinesToWidth(tt.wrap, tt.editable, tt.text, tt.width, tabWidth) assert.Equal(t, tt.expectedWrappedLines, wrappedLines) if tt.expectedWrappedLinesIndices != nil { assert.Equal(t, tt.expectedWrappedLinesIndices, wrappedLinesIndices) } if tt.expectedOriginalLinesIndices != nil { assert.Equal(t, tt.expectedOriginalLinesIndices, originalLinesIndices) } // As a sanity check, also test that gocui's line wrapping behaves the same way view := gocui.NewView("", 0, 0, tt.width+1, 1000, gocui.OutputNormal) view.TabWidth = tabWidth assert.Equal(t, tt.width, view.InnerWidth()) view.Wrap = tt.wrap view.Editable = tt.editable view.SetContent(tt.text) assert.Equal(t, wrappedLines, view.ViewBufferLines()) }) } } lazygit-0.50.0+ds1/pkg/utils/once_writer.go000066400000000000000000000007751500612110400205310ustar00rootroot00000000000000package utils import ( "io" "sync" ) // This wraps a writer and ensures that before we actually write anything we call a given function first type OnceWriter struct { writer io.Writer once sync.Once f func() } var _ io.Writer = &OnceWriter{} func NewOnceWriter(writer io.Writer, f func()) *OnceWriter { return &OnceWriter{ writer: writer, f: f, } } func (self *OnceWriter) Write(p []byte) (n int, err error) { self.once.Do(func() { self.f() }) return self.writer.Write(p) } lazygit-0.50.0+ds1/pkg/utils/once_writer_test.go000066400000000000000000000005601500612110400215600ustar00rootroot00000000000000package utils import ( "bytes" "testing" ) func TestOnceWriter(t *testing.T) { innerWriter := bytes.NewBuffer(nil) counter := 0 onceWriter := NewOnceWriter(innerWriter, func() { counter += 1 }) _, _ = onceWriter.Write([]byte("hello")) _, _ = onceWriter.Write([]byte("hello")) if counter != 1 { t.Errorf("expected counter to be 1, got %d", counter) } } lazygit-0.50.0+ds1/pkg/utils/rebase_todo.go000066400000000000000000000221031500612110400204640ustar00rootroot00000000000000package utils import ( "bytes" "errors" "fmt" "os" "slices" "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) type Todo struct { Hash string // for todos that have one, e.g. pick, drop, fixup, etc. Ref string // for update-ref todos } type TodoChange struct { Hash string NewAction todo.TodoCommand } // Read a git-rebase-todo file, change the actions for the given commits, // and write it back func EditRebaseTodo(filePath string, changes []TodoChange, commentChar byte) error { todos, err := ReadRebaseTodoFile(filePath, commentChar) if err != nil { return err } matchCount := 0 for i := range todos { t := &todos[i] // This is a nested loop, but it's ok because the number of todos should be small for _, change := range changes { if equalHash(t.Commit, change.Hash) { matchCount++ t.Command = change.NewAction } } } if matchCount < len(changes) { // Should never get here return errors.New("Some todos not found in git-rebase-todo") } return WriteRebaseTodoFile(filePath, todos, commentChar) } func equalHash(a, b string) bool { if len(a) == 0 && len(b) == 0 { return true } commonLength := min(len(a), len(b)) return commonLength > 0 && a[:commonLength] == b[:commonLength] } func findTodo(todos []todo.Todo, todoToFind Todo) (int, bool) { _, idx, ok := lo.FindIndexOf(todos, func(t todo.Todo) bool { // For update-ref todos we also must compare the Ref (they have an empty hash) return equalHash(t.Commit, todoToFind.Hash) && t.Ref == todoToFind.Ref }) return idx, ok } func ReadRebaseTodoFile(fileName string, commentChar byte) ([]todo.Todo, error) { f, err := os.Open(fileName) if err != nil { return nil, err } todos, err := todo.Parse(f, commentChar) err2 := f.Close() if err == nil { err = err2 } return todos, err } func WriteRebaseTodoFile(fileName string, todos []todo.Todo, commentChar byte) error { f, err := os.Create(fileName) if err != nil { return err } err = todo.Write(f, todos, commentChar) err2 := f.Close() if err == nil { err = err2 } return err } func todosToString(todos []todo.Todo, commentChar byte) ([]byte, error) { buffer := bytes.Buffer{} err := todo.Write(&buffer, todos, commentChar) return buffer.Bytes(), err } func PrependStrToTodoFile(filePath string, linesToPrepend []byte) error { existingContent, err := os.ReadFile(filePath) if err != nil { return err } linesToPrepend = append(linesToPrepend, existingContent...) return os.WriteFile(filePath, linesToPrepend, 0o644) } // Unlike the other functions in this file, which write the changed todos file // back to disk, this one returns the new content as a byte slice. This is // because when deleting update-ref todos, we must perform a "git rebase // --edit-todo" command to pass the changed todos to git so that it can do some // housekeeping around the deleted todos. This can only be done by our caller. func DeleteTodos(fileName string, todosToDelete []Todo, commentChar byte) ([]byte, error) { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return nil, err } rearrangedTodos, err := deleteTodos(todos, todosToDelete) if err != nil { return nil, err } return todosToString(rearrangedTodos, commentChar) } func deleteTodos(todos []todo.Todo, todosToDelete []Todo) ([]todo.Todo, error) { for _, todoToDelete := range todosToDelete { idx, ok := findTodo(todos, todoToDelete) if !ok { // Should never happen return []todo.Todo{}, fmt.Errorf("Todo %s not found in git-rebase-todo", todoToDelete.Hash) } todos = Remove(todos, idx) } return todos, nil } func MoveTodosDown(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } rearrangedTodos, err := moveTodosDown(todos, todosToMove, isInRebase) if err != nil { return err } return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar) } func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } rearrangedTodos, err := moveTodosUp(todos, todosToMove, isInRebase) if err != nil { return err } return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar) } func moveTodoDown(todos []todo.Todo, todoToMove Todo, isInRebase bool) ([]todo.Todo, error) { rearrangedTodos, err := moveTodoUp(lo.Reverse(todos), todoToMove, isInRebase) return lo.Reverse(rearrangedTodos), err } func moveTodosDown(todos []todo.Todo, todosToMove []Todo, isInRebase bool) ([]todo.Todo, error) { rearrangedTodos, err := moveTodosUp(lo.Reverse(todos), lo.Reverse(todosToMove), isInRebase) return lo.Reverse(rearrangedTodos), err } func moveTodoUp(todos []todo.Todo, todoToMove Todo, isInRebase bool) ([]todo.Todo, error) { sourceIdx, ok := findTodo(todos, todoToMove) if !ok { // Should never happen return []todo.Todo{}, fmt.Errorf("Todo %s not found in git-rebase-todo", todoToMove.Hash) } // The todos are ordered backwards compared to our model commits, so // actually move the commit _down_ in the todos slice (i.e. towards // the end of the slice) // Find the next todo that we show in lazygit's commits view (skipping the rest) _, skip, ok := lo.FindIndexOf(todos[sourceIdx+1:], func(t todo.Todo) bool { return isRenderedTodo(t, isInRebase) }) if !ok { // We expect callers to guard against this return []todo.Todo{}, errors.New("Destination position for moving todo is out of range") } destinationIdx := sourceIdx + 1 + skip rearrangedTodos := MoveElement(todos, sourceIdx, destinationIdx) return rearrangedTodos, nil } func moveTodosUp(todos []todo.Todo, todosToMove []Todo, isInRebase bool) ([]todo.Todo, error) { for _, todoToMove := range todosToMove { var newTodos []todo.Todo newTodos, err := moveTodoUp(todos, todoToMove, isInRebase) if err != nil { return nil, err } todos = newTodos } return todos, nil } func MoveFixupCommitDown(fileName string, originalHash string, fixupHash string, changeToFixup bool, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } newTodos, err := moveFixupCommitDown(todos, originalHash, fixupHash, changeToFixup) if err != nil { return err } return WriteRebaseTodoFile(fileName, newTodos, commentChar) } func moveFixupCommitDown(todos []todo.Todo, originalHash string, fixupHash string, changeToFixup bool) ([]todo.Todo, error) { isOriginal := func(t todo.Todo) bool { return (t.Command == todo.Pick || t.Command == todo.Merge) && equalHash(t.Commit, originalHash) } isFixup := func(t todo.Todo) bool { return t.Command == todo.Pick && equalHash(t.Commit, fixupHash) } originalHashCount := lo.CountBy(todos, isOriginal) if originalHashCount != 1 { return nil, fmt.Errorf("Expected exactly one original hash, found %d", originalHashCount) } fixupHashCount := lo.CountBy(todos, isFixup) if fixupHashCount != 1 { return nil, fmt.Errorf("Expected exactly one fixup hash, found %d", fixupHashCount) } _, fixupIndex, _ := lo.FindIndexOf(todos, isFixup) _, originalIndex, _ := lo.FindIndexOf(todos, isOriginal) newTodos := MoveElement(todos, fixupIndex, originalIndex+1) if changeToFixup { newTodos[originalIndex+1].Command = todo.Fixup } return newTodos, nil } func RemoveUpdateRefsForCopiedBranch(fileName string, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } // Filter out comments todos = lo.Filter(todos, func(t todo.Todo, _ int) bool { return t.Command != todo.Comment }) // Delete any update-ref todos at the end of the todo list. These are not // part of a stack of branches, and so shouldn't be updated. This makes it // possible to create a copy of a branch and rebase the copy without // affecting the original branch. if _, i, found := lo.FindLastIndexOf(todos, func(t todo.Todo) bool { return t.Command != todo.UpdateRef }); found && i < len(todos)-1 { todos = slices.Delete(todos, i+1, len(todos)) return WriteRebaseTodoFile(fileName, todos, commentChar) } return nil } // We render a todo in the commits view if it's a commit or if it's an // update-ref or exec. We don't render label, reset, or comment lines. func isRenderedTodo(t todo.Todo, isInRebase bool) bool { return t.Commit != "" || (isInRebase && (t.Command == todo.UpdateRef || t.Command == todo.Exec)) } func DropMergeCommit(fileName string, hash string, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } newTodos, err := dropMergeCommit(todos, hash) if err != nil { return err } return WriteRebaseTodoFile(fileName, newTodos, commentChar) } func dropMergeCommit(todos []todo.Todo, hash string) ([]todo.Todo, error) { isMerge := func(t todo.Todo) bool { return t.Command == todo.Merge && t.Flag == "-C" && equalHash(t.Commit, hash) } if lo.CountBy(todos, isMerge) != 1 { return nil, fmt.Errorf("Expected exactly one merge commit with hash %s", hash) } _, idx, _ := lo.FindIndexOf(todos, isMerge) return slices.Delete(todos, idx, idx+1), nil } lazygit-0.50.0+ds1/pkg/utils/rebase_todo_test.go000066400000000000000000000377201500612110400215360ustar00rootroot00000000000000package utils import ( "errors" "fmt" "testing" "github.com/stefanhaller/git-todo-parser/todo" "github.com/stretchr/testify/assert" ) func TestRebaseCommands_moveTodoDown(t *testing.T) { type scenario struct { testName string todos []todo.Todo todoToMoveDown Todo isInRebase bool expectedErr string expectedTodos []todo.Todo } scenarios := []scenario{ { testName: "simple case 1 - move to beginning", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todoToMoveDown: Todo{Hash: "5678"}, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "abcd"}, }, }, { testName: "simple case 2 - move from end", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todoToMoveDown: Todo{Hash: "abcd"}, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "5678"}, }, }, { testName: "move update-ref todo", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, }, todoToMoveDown: Todo{Ref: "refs/heads/some_branch"}, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "5678"}, }, }, { testName: "move across update-ref todo in rebase", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "5678"}, }, todoToMoveDown: Todo{Hash: "5678"}, isInRebase: true, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, }, }, { testName: "move across update-ref todo outside of rebase", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "5678"}, }, todoToMoveDown: Todo{Hash: "5678"}, isInRebase: false, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "1234"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, }, }, { testName: "move across exec todo", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Exec, ExecCommand: "make test"}, {Command: todo.Pick, Commit: "5678"}, }, todoToMoveDown: Todo{Hash: "5678"}, isInRebase: true, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Exec, ExecCommand: "make test"}, }, }, { testName: "skip an invisible todo", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "abcd"}, {Command: todo.Label, Label: "myLabel"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "def0"}, }, todoToMoveDown: Todo{Hash: "5678"}, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, {Command: todo.Label, Label: "myLabel"}, {Command: todo.Pick, Commit: "def0"}, }, }, // Error cases { testName: "commit not found", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todoToMoveDown: Todo{Hash: "def0"}, expectedErr: "Todo def0 not found in git-rebase-todo", expectedTodos: []todo.Todo{}, }, { testName: "trying to move first commit down", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todoToMoveDown: Todo{Hash: "1234"}, expectedErr: "Destination position for moving todo is out of range", expectedTodos: []todo.Todo{}, }, { testName: "trying to move commit down when all commits before are invisible", todos: []todo.Todo{ {Command: todo.Label, Label: "myLabel"}, {Command: todo.Reset, Label: "otherlabel"}, {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, }, todoToMoveDown: Todo{Hash: "1234"}, expectedErr: "Destination position for moving todo is out of range", expectedTodos: []todo.Todo{}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { rearrangedTodos, err := moveTodoDown(s.todos, s.todoToMoveDown, s.isInRebase) if s.expectedErr == "" { assert.NoError(t, err) } else { assert.ErrorContains(t, err, s.expectedErr) } assert.Equal(t, s.expectedTodos, rearrangedTodos) }, ) } } func TestRebaseCommands_moveTodoUp(t *testing.T) { type scenario struct { testName string todos []todo.Todo todoToMoveUp Todo isInRebase bool expectedErr string expectedTodos []todo.Todo } scenarios := []scenario{ { testName: "simple case 1 - move to end", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todoToMoveUp: Todo{Hash: "5678"}, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "5678"}, }, }, { testName: "simple case 2 - move from beginning", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todoToMoveUp: Todo{Hash: "1234"}, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "abcd"}, }, }, { testName: "move update-ref todo", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "5678"}, }, todoToMoveUp: Todo{Ref: "refs/heads/some_branch"}, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, }, }, { testName: "move across update-ref todo in rebase", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "5678"}, }, todoToMoveUp: Todo{Hash: "1234"}, isInRebase: true, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, }, }, { testName: "move across update-ref todo outside of rebase", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "5678"}, }, todoToMoveUp: Todo{Hash: "1234"}, isInRebase: false, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "1234"}, }, }, { testName: "move across exec todo", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Exec, ExecCommand: "make test"}, {Command: todo.Pick, Commit: "5678"}, }, todoToMoveUp: Todo{Hash: "1234"}, isInRebase: true, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Exec, ExecCommand: "make test"}, {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, }, }, { testName: "skip an invisible todo", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "abcd"}, {Command: todo.Label, Label: "myLabel"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "def0"}, }, todoToMoveUp: Todo{Hash: "abcd"}, expectedErr: "", expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Label, Label: "myLabel"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "def0"}, }, }, // Error cases { testName: "commit not found", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todoToMoveUp: Todo{Hash: "def0"}, expectedErr: "Todo def0 not found in git-rebase-todo", expectedTodos: []todo.Todo{}, }, { testName: "trying to move last commit up", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todoToMoveUp: Todo{Hash: "abcd"}, expectedErr: "Destination position for moving todo is out of range", expectedTodos: []todo.Todo{}, }, { testName: "trying to move commit up when all commits after it are invisible", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Label, Label: "myLabel"}, {Command: todo.Reset, Label: "otherlabel"}, }, todoToMoveUp: Todo{Hash: "5678"}, expectedErr: "Destination position for moving todo is out of range", expectedTodos: []todo.Todo{}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { rearrangedTodos, err := moveTodoUp(s.todos, s.todoToMoveUp, s.isInRebase) if s.expectedErr == "" { assert.NoError(t, err) } else { assert.ErrorContains(t, err, s.expectedErr) } assert.Equal(t, s.expectedTodos, rearrangedTodos) }, ) } } func TestRebaseCommands_moveFixupCommitDown(t *testing.T) { scenarios := []struct { name string todos []todo.Todo originalHash string fixupHash string changeToFixup bool expectedTodos []todo.Todo expectedErr error }{ { name: "fixup commit is the last commit (change to fixup)", todos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, {Command: todo.Pick, Commit: "fixup"}, }, originalHash: "original", fixupHash: "fixup", changeToFixup: true, expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, {Command: todo.Fixup, Commit: "fixup"}, }, expectedErr: nil, }, { name: "fixup commit is the last commit (don't change to fixup)", todos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, {Command: todo.Pick, Commit: "fixup"}, }, originalHash: "original", fixupHash: "fixup", changeToFixup: false, expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, {Command: todo.Pick, Commit: "fixup"}, }, expectedErr: nil, }, { name: "fixup commit is separated from original commit", todos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, {Command: todo.Pick, Commit: "other"}, {Command: todo.Pick, Commit: "fixup"}, }, originalHash: "original", fixupHash: "fixup", changeToFixup: true, expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, {Command: todo.Fixup, Commit: "fixup"}, {Command: todo.Pick, Commit: "other"}, }, expectedErr: nil, }, { name: "fixup commit is separated from original merge commit", todos: []todo.Todo{ {Command: todo.Merge, Commit: "original"}, {Command: todo.Pick, Commit: "other"}, {Command: todo.Pick, Commit: "fixup"}, }, originalHash: "original", fixupHash: "fixup", changeToFixup: true, expectedTodos: []todo.Todo{ {Command: todo.Merge, Commit: "original"}, {Command: todo.Fixup, Commit: "fixup"}, {Command: todo.Pick, Commit: "other"}, }, expectedErr: nil, }, { name: "More original hashes than expected", todos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, {Command: todo.Pick, Commit: "original"}, {Command: todo.Pick, Commit: "fixup"}, }, originalHash: "original", fixupHash: "fixup", changeToFixup: true, expectedTodos: nil, expectedErr: errors.New("Expected exactly one original hash, found 2"), }, { name: "More fixup hashes than expected", todos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, {Command: todo.Pick, Commit: "fixup"}, {Command: todo.Pick, Commit: "fixup"}, }, originalHash: "original", fixupHash: "fixup", changeToFixup: true, expectedTodos: nil, expectedErr: errors.New("Expected exactly one fixup hash, found 2"), }, { name: "No fixup hashes found", todos: []todo.Todo{ {Command: todo.Pick, Commit: "original"}, }, originalHash: "original", fixupHash: "fixup", changeToFixup: true, expectedTodos: nil, expectedErr: errors.New("Expected exactly one fixup hash, found 0"), }, { name: "No original hashes found", todos: []todo.Todo{ {Command: todo.Pick, Commit: "fixup"}, }, originalHash: "original", fixupHash: "fixup", changeToFixup: true, expectedTodos: nil, expectedErr: errors.New("Expected exactly one original hash, found 0"), }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { actualTodos, actualErr := moveFixupCommitDown(scenario.todos, scenario.originalHash, scenario.fixupHash, scenario.changeToFixup) if scenario.expectedErr == nil { assert.NoError(t, actualErr) } else { assert.EqualError(t, actualErr, scenario.expectedErr.Error()) } assert.EqualValues(t, scenario.expectedTodos, actualTodos) }) } } func TestRebaseCommands_deleteTodos(t *testing.T) { scenarios := []struct { name string todos []todo.Todo todosToDelete []Todo expectedTodos []todo.Todo expectedErr error }{ { name: "success", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "abcd"}, }, todosToDelete: []Todo{ {Ref: "refs/heads/some_branch"}, {Hash: "abcd"}, }, expectedTodos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, }, expectedErr: nil, }, { name: "failure", todos: []todo.Todo{ {Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "5678"}, }, todosToDelete: []Todo{ {Hash: "abcd"}, }, expectedTodos: []todo.Todo{}, expectedErr: errors.New("Todo abcd not found in git-rebase-todo"), }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { actualTodos, actualErr := deleteTodos(scenario.todos, scenario.todosToDelete) if scenario.expectedErr == nil { assert.NoError(t, actualErr) } else { assert.EqualError(t, actualErr, scenario.expectedErr.Error()) } assert.EqualValues(t, scenario.expectedTodos, actualTodos) }) } } func Test_equalHash(t *testing.T) { scenarios := []struct { a string b string expected bool }{ {"", "", true}, {"", "123", false}, {"123", "", false}, {"123", "123", true}, {"123", "123abc", true}, {"123abc", "123", true}, {"123", "a", false}, {"1", "abc", false}, } for _, scenario := range scenarios { t.Run(fmt.Sprintf("'%s' vs. '%s'", scenario.a, scenario.b), func(t *testing.T) { assert.Equal(t, scenario.expected, equalHash(scenario.a, scenario.b)) }) } } lazygit-0.50.0+ds1/pkg/utils/regexp.go000066400000000000000000000005001500612110400174650ustar00rootroot00000000000000package utils import "regexp" func FindNamedMatches(regex *regexp.Regexp, str string) map[string]string { match := regex.FindStringSubmatch(str) if len(match) == 0 { return nil } results := map[string]string{} for i, value := range match[1:] { results[regex.SubexpNames()[i+1]] = value } return results } lazygit-0.50.0+ds1/pkg/utils/regexp_test.go000066400000000000000000000017051500612110400205340ustar00rootroot00000000000000package utils import ( "reflect" "regexp" "testing" ) func TestFindNamedMatches(t *testing.T) { scenarios := []struct { regex *regexp.Regexp input string expected map[string]string }{ { regexp.MustCompile(`^(?P\w+)`), "hello world", map[string]string{ "name": "hello", }, }, { regexp.MustCompile(`^https?://.*/(?P.*)/(?P.*?)(\.git)?$`), "https://my_username@bitbucket.org/johndoe/social_network.git", map[string]string{ "owner": "johndoe", "repo": "social_network", "": ".git", // unnamed capture group }, }, { regexp.MustCompile(`(?Phello) world`), "yo world", nil, }, } for _, scenario := range scenarios { actual := FindNamedMatches(scenario.regex, scenario.input) if !reflect.DeepEqual(actual, scenario.expected) { t.Errorf("FindNamedMatches(%s, %s) == %s, expected %s", scenario.regex, scenario.input, actual, scenario.expected) } } } lazygit-0.50.0+ds1/pkg/utils/search.go000066400000000000000000000043131500612110400174460ustar00rootroot00000000000000package utils import ( "strings" "github.com/sahilm/fuzzy" "github.com/samber/lo" ) func FilterStrings(needle string, haystack []string, useFuzzySearch bool) []string { if needle == "" { return []string{} } matches := Find(needle, haystack, useFuzzySearch) return lo.Map(matches, func(match fuzzy.Match, _ int) string { return match.Str }) } // Duplicated from the fuzzy package because it's private there type stringSource []string func (ss stringSource) String(i int) string { return ss[i] } func (ss stringSource) Len() int { return len(ss) } // Drop-in replacement for fuzzy.Find (except that it doesn't fill out // MatchedIndexes or Score, but we are not using these) func FindSubstrings(pattern string, data []string) fuzzy.Matches { return FindSubstringsFrom(pattern, stringSource(data)) } // Drop-in replacement for fuzzy.FindFrom (except that it doesn't fill out // MatchedIndexes or Score, but we are not using these) func FindSubstringsFrom(pattern string, data fuzzy.Source) fuzzy.Matches { substrings := strings.Fields(pattern) result := fuzzy.Matches{} outer: for i := 0; i < data.Len(); i++ { s := data.String(i) for _, sub := range substrings { if !CaseAwareContains(s, sub) { continue outer } } result = append(result, fuzzy.Match{Str: s, Index: i}) } return result } func Find(pattern string, data []string, useFuzzySearch bool) fuzzy.Matches { if useFuzzySearch { return fuzzy.Find(pattern, data) } return FindSubstrings(pattern, data) } func FindFrom(pattern string, data fuzzy.Source, useFuzzySearch bool) fuzzy.Matches { if useFuzzySearch { return fuzzy.FindFrom(pattern, data) } return FindSubstringsFrom(pattern, data) } func CaseAwareContains(haystack, needle string) bool { // if needle contains an uppercase letter, we'll do a case sensitive search if ContainsUppercase(needle) { return strings.Contains(haystack, needle) } return CaseInsensitiveContains(haystack, needle) } func ContainsUppercase(s string) bool { for _, r := range s { if r >= 'A' && r <= 'Z' { return true } } return false } func CaseInsensitiveContains(haystack, needle string) bool { return strings.Contains( strings.ToLower(haystack), strings.ToLower(needle), ) } lazygit-0.50.0+ds1/pkg/utils/search_test.go000066400000000000000000000057771500612110400205240ustar00rootroot00000000000000package utils import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func TestFilterStrings(t *testing.T) { type scenario struct { needle string haystack []string useFuzzySearch bool expected []string } scenarios := []scenario{ { needle: "", haystack: []string{"test"}, useFuzzySearch: true, expected: []string{}, }, { needle: "test", haystack: []string{"test"}, useFuzzySearch: true, expected: []string{"test"}, }, { needle: "o", haystack: []string{"a", "o", "e"}, useFuzzySearch: true, expected: []string{"o"}, }, { needle: "mybranch", haystack: []string{"my_branch", "mybranch", "branch", "this is my branch"}, useFuzzySearch: true, expected: []string{"mybranch", "my_branch", "this is my branch"}, }, { needle: "test", haystack: []string{"not a good match", "this 'test' is a good match", "test"}, useFuzzySearch: true, expected: []string{"test", "this 'test' is a good match"}, }, { needle: "test", haystack: []string{"Test"}, useFuzzySearch: true, expected: []string{"Test"}, }, { needle: "test", haystack: []string{"integration-testing", "t_e_s_t"}, useFuzzySearch: false, expected: []string{"integration-testing"}, }, { needle: "integr test", haystack: []string{"integration-testing", "testing-integration"}, useFuzzySearch: false, expected: []string{"integration-testing", "testing-integration"}, }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, FilterStrings(s.needle, s.haystack, s.useFuzzySearch)) } } func TestCaseInsensitiveContains(t *testing.T) { testCases := []struct { haystack string needle string expected bool }{ {"Hello, World!", "world", true}, // Case-insensitive match {"Hello, World!", "WORLD", true}, // Case-insensitive match {"Hello, World!", "orl", true}, // Case-insensitive match {"Hello, World!", "o, W", true}, // Case-insensitive match {"Hello, World!", "hello", true}, // Case-insensitive match {"Hello, World!", "Foo", false}, // No match {"Hello, World!", "Hello, World!!", false}, // No match {"Hello, World!", "", true}, // Empty needle matches {"", "Hello", false}, // Empty haystack doesn't match {"", "", true}, // Empty strings match {"", " ", false}, // Empty haystack, non-empty needle {" ", "", true}, // Non-empty haystack, empty needle } for i, testCase := range testCases { result := CaseInsensitiveContains(testCase.haystack, testCase.needle) assert.Equal(t, testCase.expected, result, fmt.Sprintf("Test case %d failed. Expected '%v', got '%v' for '%s' in '%s'", i, testCase.expected, result, testCase.needle, testCase.haystack)) } } lazygit-0.50.0+ds1/pkg/utils/slice.go000066400000000000000000000114171500612110400173030ustar00rootroot00000000000000package utils import "golang.org/x/exp/slices" // NextIndex returns the index of the element that comes after the given number func NextIndex(numbers []int, currentNumber int) int { for index, number := range numbers { if number > currentNumber { return index } } return len(numbers) - 1 } // PrevIndex returns the index that comes before the given number, cycling if we reach the end func PrevIndex(numbers []int, currentNumber int) int { end := len(numbers) - 1 for i := end; i >= 0; i-- { if numbers[i] < currentNumber { return i } } return 0 } // NextIntInCycle returns the next int in a slice, returning to the first index if we've reached the end func NextIntInCycle(sl []int, current int) int { for i, val := range sl { if val == current { if i == len(sl)-1 { return sl[0] } return sl[i+1] } } return sl[0] } // PrevIntInCycle returns the prev int in a slice, returning to the first index if we've reached the end func PrevIntInCycle(sl []int, current int) int { for i, val := range sl { if val == current { if i > 0 { return sl[i-1] } return sl[len(sl)-1] } } return sl[len(sl)-1] } func StringArraysOverlap(strArrA []string, strArrB []string) bool { for _, first := range strArrA { for _, second := range strArrB { if first == second { return true } } } return false } func Limit(values []string, limit int) []string { if len(values) > limit { return values[:limit] } return values } func LimitStr(value string, limit int) string { n := 0 for i := range value { if n >= limit { return value[:i] } n++ } return value } // Similar to a regular GroupBy, except that each item can be grouped under multiple keys, // so the callback returns a slice of keys instead of just one key. func MuiltiGroupBy[T any, K comparable](slice []T, f func(T) []K) map[K][]T { result := map[K][]T{} for _, item := range slice { for _, key := range f(item) { if _, ok := result[key]; !ok { result[key] = []T{item} } else { result[key] = append(result[key], item) } } } return result } // Returns a new slice with the element at index 'from' moved to index 'to'. // Does not mutate original slice. func MoveElement[T any](slice []T, from int, to int) []T { newSlice := make([]T, len(slice)) copy(newSlice, slice) if from == to { return newSlice } if from < to { copy(newSlice[from:to+1], newSlice[from+1:to+1]) } else { copy(newSlice[to+1:from+1], newSlice[to:from]) } newSlice[to] = slice[from] return newSlice } func ValuesAtIndices[T any](slice []T, indices []int) []T { result := make([]T, len(indices)) for i, index := range indices { // gracefully handling the situation where the index is out of bounds if index < len(slice) { result[i] = slice[index] } } return result } // returns two slices: the first is for elements that pass the test, the second for those that don't. func Partition[T any](slice []T, test func(T) bool) ([]T, []T) { left := make([]T, 0, len(slice)) right := make([]T, 0, len(slice)) for _, value := range slice { if test(value) { left = append(left, value) } else { right = append(right, value) } } return left, right } // Prepends items to the beginning of a slice. // E.g. Prepend([]int{1,2}, 3, 4) = []int{3,4,1,2} // Mutates original slice. Intended usage is to reassign the slice result to the input slice. func Prepend[T any](slice []T, values ...T) []T { return append(values, slice...) } // Removes the element at the given index. Intended usage is to reassign the result to the input slice. func Remove[T any](slice []T, index int) []T { return slices.Delete(slice, index, index+1) } // Removes the element at the 'fromIndex' and then inserts it at 'toIndex'. // Operates on the input slice. Expected use is to reassign the result to the input slice. func Move[T any](slice []T, fromIndex int, toIndex int) []T { item := slice[fromIndex] slice = Remove(slice, fromIndex) return slices.Insert(slice, toIndex, item) } // Pops item from the end of the slice and returns it, along with the updated slice // Mutates original slice. Intended usage is to reassign the slice result to the input slice. func Pop[T any](slice []T) (T, []T) { index := len(slice) - 1 value := slice[index] slice = slice[0:index] return value, slice } // Shifts item from the beginning of the slice and returns it, along with the updated slice. // Mutates original slice. Intended usage is to reassign the slice result to the input slice. func Shift[T any](slice []T) (T, []T) { value := slice[0] slice = slice[1:] return value, slice } // Compares two slices for equality func EqualSlices[T comparable](slice1 []T, slice2 []T) bool { if len(slice1) != len(slice2) { return false } for i := range slice1 { if slice1[i] != slice2[i] { return false } } return true } lazygit-0.50.0+ds1/pkg/utils/slice_test.go000066400000000000000000000111401500612110400203330ustar00rootroot00000000000000package utils import ( "testing" "github.com/stretchr/testify/assert" ) func TestNextIndex(t *testing.T) { type scenario struct { testName string list []int element int expected int } scenarios := []scenario{ { // I'm not really fussed about how it behaves here "no elements", []int{}, 1, -1, }, { "one element", []int{1}, 1, 0, }, { "two elements", []int{1, 2}, 1, 1, }, { "two elements, giving second one", []int{1, 2}, 2, 1, }, { "three elements, giving second one", []int{1, 2, 3}, 2, 2, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { assert.EqualValues(t, s.expected, NextIndex(s.list, s.element)) }) } } func TestPrevIndex(t *testing.T) { type scenario struct { testName string list []int element int expected int } scenarios := []scenario{ { // I'm not really fussed about how it behaves here "no elements", []int{}, 1, 0, }, { "one element", []int{1}, 1, 0, }, { "two elements", []int{1, 2}, 1, 0, }, { "three elements, giving second one", []int{1, 2, 3}, 2, 0, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { assert.EqualValues(t, s.expected, PrevIndex(s.list, s.element)) }) } } func TestEscapeSpecialChars(t *testing.T) { type scenario struct { testName string input string expected string } scenarios := []scenario{ { "normal string", "ab", "ab", }, { "string with a special char", "a\nb", "a\\nb", }, { "multiple special chars", "\n\r\t\b\f\v", "\\n\\r\\t\\b\\f\\v", }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { assert.EqualValues(t, s.expected, EscapeSpecialChars(s.input)) }) } } func TestLimit(t *testing.T) { for _, test := range []struct { values []string limit int want []string }{ { values: []string{"a", "b", "c"}, limit: 3, want: []string{"a", "b", "c"}, }, { values: []string{"a", "b", "c"}, limit: 4, want: []string{"a", "b", "c"}, }, { values: []string{"a", "b", "c"}, limit: 2, want: []string{"a", "b"}, }, { values: []string{"a", "b", "c"}, limit: 1, want: []string{"a"}, }, { values: []string{"a", "b", "c"}, limit: 0, want: []string{}, }, { values: []string{}, limit: 0, want: []string{}, }, } { if got := Limit(test.values, test.limit); !assert.EqualValues(t, got, test.want) { t.Errorf("Limit(%v, %d) = %v; want %v", test.values, test.limit, got, test.want) } } } func TestLimitStr(t *testing.T) { for _, test := range []struct { values string limit int want string }{ { values: "", limit: 10, want: "", }, { values: "", limit: 0, want: "", }, { values: "a", limit: 1, want: "a", }, { values: "ab", limit: 2, want: "ab", }, { values: "abc", limit: 3, want: "abc", }, { values: "abcd", limit: 3, want: "abc", }, { values: "abcde", limit: 3, want: "abc", }, { values: "あいう", limit: 1, want: "あ", }, { values: "あいう", limit: 2, want: "あい", }, } { if got := LimitStr(test.values, test.limit); !assert.EqualValues(t, got, test.want) { t.Errorf("LimitString(%v, %d) = %v; want %v", test.values, test.limit, got, test.want) } } } func TestMoveElement(t *testing.T) { type scenario struct { testName string list []int from int to int expected []int } scenarios := []scenario{ { "no elements", []int{}, 0, 0, []int{}, }, { "one element", []int{1}, 0, 0, []int{1}, }, { "two elements, moving first to second", []int{1, 2}, 0, 1, []int{2, 1}, }, { "two elements, moving second to first", []int{1, 2}, 1, 0, []int{2, 1}, }, { "three elements, moving first to second", []int{1, 2, 3}, 0, 1, []int{2, 1, 3}, }, { "three elements, moving second to first", []int{1, 2, 3}, 1, 0, []int{2, 1, 3}, }, { "three elements, moving second to third", []int{1, 2, 3}, 1, 2, []int{1, 3, 2}, }, { "three elements, moving third to second", []int{1, 2, 3}, 2, 1, []int{1, 3, 2}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { assert.EqualValues(t, s.expected, MoveElement(s.list, s.from, s.to)) }) } t.Run("from out of bounds", func(t *testing.T) { assert.Panics(t, func() { MoveElement([]int{1, 2, 3}, 3, 0) }) }) } lazygit-0.50.0+ds1/pkg/utils/string_pool.go000066400000000000000000000004771500612110400205470ustar00rootroot00000000000000package utils import "sync" // A simple string pool implementation that can help reduce memory usage for // cases where the same string is used multiple times. type StringPool struct { sync.Map } func (self *StringPool) Add(s string) *string { poolEntry, _ := self.LoadOrStore(s, &s) return poolEntry.(*string) } lazygit-0.50.0+ds1/pkg/utils/string_stack.go000066400000000000000000000007051500612110400206750ustar00rootroot00000000000000package utils type StringStack struct { stack []string } func (self *StringStack) Push(s string) { self.stack = append(self.stack, s) } func (self *StringStack) Pop() string { if len(self.stack) == 0 { return "" } n := len(self.stack) - 1 last := self.stack[n] self.stack = self.stack[:n] return last } func (self *StringStack) IsEmpty() bool { return len(self.stack) == 0 } func (self *StringStack) Clear() { self.stack = []string{} } lazygit-0.50.0+ds1/pkg/utils/template.go000066400000000000000000000014321500612110400200130ustar00rootroot00000000000000package utils import ( "bytes" "strings" "text/template" ) func ResolveTemplate(templateStr string, object interface{}, funcs template.FuncMap) (string, error) { tmpl, err := template.New("template").Funcs(funcs).Option("missingkey=error").Parse(templateStr) if err != nil { return "", err } var buf bytes.Buffer if err := tmpl.Execute(&buf, object); err != nil { return "", err } return buf.String(), nil } // ResolvePlaceholderString populates a template with values func ResolvePlaceholderString(str string, arguments map[string]string) string { oldnews := make([]string, 0, len(arguments)*4) for key, value := range arguments { oldnews = append(oldnews, "{{"+key+"}}", value, "{{."+key+"}}", value, ) } return strings.NewReplacer(oldnews...).Replace(str) } lazygit-0.50.0+ds1/pkg/utils/template_test.go000066400000000000000000000021631500612110400210540ustar00rootroot00000000000000package utils import ( "testing" "github.com/stretchr/testify/assert" ) // TestResolvePlaceholderString is a function. func TestResolvePlaceholderString(t *testing.T) { type scenario struct { templateString string arguments map[string]string expected string } scenarios := []scenario{ { "", map[string]string{}, "", }, { "hello", map[string]string{}, "hello", }, { "hello {{arg}}", map[string]string{}, "hello {{arg}}", }, { "hello {{arg}}", map[string]string{"arg": "there"}, "hello there", }, { "hello", map[string]string{"arg": "there"}, "hello", }, { "{{nothing}}", map[string]string{"nothing": ""}, "", }, { "{{}} {{ this }} { should not throw}} an {{{{}}}} error", map[string]string{ "blah": "blah", "this": "won't match", }, "{{}} {{ this }} { should not throw}} an {{{{}}}} error", }, { "{{a}}", map[string]string{ "a": "X{{.a}}X", }, "X{{.a}}X", }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, ResolvePlaceholderString(s.templateString, s.arguments)) } } lazygit-0.50.0+ds1/pkg/utils/thread_safe_map.go000066400000000000000000000027761500612110400213160ustar00rootroot00000000000000package utils import "sync" type ThreadSafeMap[K comparable, V any] struct { mutex sync.RWMutex innerMap map[K]V } func NewThreadSafeMap[K comparable, V any]() *ThreadSafeMap[K, V] { return &ThreadSafeMap[K, V]{ innerMap: make(map[K]V), } } func (m *ThreadSafeMap[K, V]) Get(key K) (V, bool) { m.mutex.RLock() defer m.mutex.RUnlock() value, ok := m.innerMap[key] return value, ok } func (m *ThreadSafeMap[K, V]) Set(key K, value V) { m.mutex.Lock() defer m.mutex.Unlock() m.innerMap[key] = value } func (m *ThreadSafeMap[K, V]) Delete(key K) { m.mutex.Lock() defer m.mutex.Unlock() delete(m.innerMap, key) } func (m *ThreadSafeMap[K, V]) Keys() []K { m.mutex.RLock() defer m.mutex.RUnlock() keys := make([]K, 0, len(m.innerMap)) for key := range m.innerMap { keys = append(keys, key) } return keys } func (m *ThreadSafeMap[K, V]) Values() []V { m.mutex.RLock() defer m.mutex.RUnlock() values := make([]V, 0, len(m.innerMap)) for _, value := range m.innerMap { values = append(values, value) } return values } func (m *ThreadSafeMap[K, V]) Len() int { m.mutex.RLock() defer m.mutex.RUnlock() return len(m.innerMap) } func (m *ThreadSafeMap[K, V]) Clear() { m.mutex.Lock() defer m.mutex.Unlock() m.innerMap = make(map[K]V) } func (m *ThreadSafeMap[K, V]) IsEmpty() bool { m.mutex.RLock() defer m.mutex.RUnlock() return len(m.innerMap) == 0 } func (m *ThreadSafeMap[K, V]) Has(key K) bool { m.mutex.RLock() defer m.mutex.RUnlock() _, ok := m.innerMap[key] return ok } lazygit-0.50.0+ds1/pkg/utils/thread_safe_map_test.go000066400000000000000000000015421500612110400223430ustar00rootroot00000000000000package utils import ( "testing" ) func TestThreadSafeMap(t *testing.T) { m := NewThreadSafeMap[int, int]() m.Set(1, 1) m.Set(2, 2) m.Set(3, 3) if m.Len() != 3 { t.Errorf("Expected length to be 3, got %d", m.Len()) } if !m.Has(1) { t.Errorf("Expected to have key 1") } if m.Has(4) { t.Errorf("Expected to not have key 4") } if _, ok := m.Get(1); !ok { t.Errorf("Expected to have key 1") } if _, ok := m.Get(4); ok { t.Errorf("Expected to not have key 4") } m.Delete(1) if m.Has(1) { t.Errorf("Expected to not have key 1") } m.Clear() if m.Len() != 0 { t.Errorf("Expected length to be 0, got %d", m.Len()) } } func TestThreadSafeMapConcurrentReadWrite(t *testing.T) { m := NewThreadSafeMap[int, int]() go func() { for i := 0; i < 10000; i++ { m.Set(0, 0) } }() for i := 0; i < 10000; i++ { m.Get(0) } } lazygit-0.50.0+ds1/pkg/utils/utils.go000066400000000000000000000042031500612110400173370ustar00rootroot00000000000000package utils import ( "encoding/json" "fmt" "os" "regexp" "runtime" "strconv" "strings" "time" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" ) // GetProjectRoot returns the path to the root of the project. Only to be used // in testing contexts, as with binaries it's unlikely this path will exist on // the machine func GetProjectRoot() string { dir, err := os.Getwd() if err != nil { panic(err) } return strings.Split(dir, "lazygit")[0] + "lazygit" } // Loader dumps a string to be displayed as a loader func Loader(now time.Time, config config.SpinnerConfig) string { milliseconds := now.UnixMilli() index := milliseconds / int64(config.Rate) % int64(len(config.Frames)) return config.Frames[index] } func SortRange(x int, y int) (int, int) { if x < y { return x, y } return y, x } func AsJson(i interface{}) string { bytes, _ := json.MarshalIndent(i, "", " ") return string(bytes) } // used to keep a number n between 0 and max, allowing for wraparounds func ModuloWithWrap(n, max int) int { if max == 0 { return 0 } if n >= max { return n % max } else if n < 0 { return max + n } else { return n } } func FindStringSubmatch(str string, regexpStr string) (bool, []string) { re := regexp.MustCompile(regexpStr) match := re.FindStringSubmatch(str) return len(match) > 0, match } func MustConvertToInt(s string) int { i, err := strconv.Atoi(s) if err != nil { panic(err) } return i } // Safe will close tcell if a panic occurs so that we don't end up in a malformed // terminal state func Safe(f func()) { _ = SafeWithError(func() error { f(); return nil }) } func SafeWithError(f func() error) error { panicking := true defer func() { if panicking && gocui.Screen != nil { gocui.Screen.Fini() } }() err := f() panicking = false return err } func StackTrace() string { buf := make([]byte, 10000) n := runtime.Stack(buf, false) return fmt.Sprintf("%s\n", buf[:n]) } // returns the path of the file that calls the function. // 'skip' is the number of stack frames to skip. func FilePath(skip int) string { _, path, _, _ := runtime.Caller(skip) return path } lazygit-0.50.0+ds1/pkg/utils/utils_test.go000066400000000000000000000027611500612110400204050ustar00rootroot00000000000000package utils import ( "testing" "github.com/stretchr/testify/assert" ) func TestAsJson(t *testing.T) { type myStruct struct { a string } output := AsJson(&myStruct{a: "foo"}) // no idea why this is returning empty hashes but it's works in the app ¯\_(ツ)_/¯ assert.EqualValues(t, "{}", output) } func TestSafeTruncate(t *testing.T) { type scenario struct { str string limit int expected string } scenarios := []scenario{ { str: "", limit: 0, expected: "", }, { str: "12345", limit: 3, expected: "123", }, { str: "12345", limit: 4, expected: "1234", }, { str: "12345", limit: 5, expected: "12345", }, { str: "12345", limit: 6, expected: "12345", }, } for _, s := range scenarios { assert.EqualValues(t, s.expected, SafeTruncate(s.str, s.limit)) } } func TestModuloWithWrap(t *testing.T) { type scenario struct { n int max int expected int } scenarios := []scenario{ { n: 0, max: 0, expected: 0, }, { n: 0, max: 1, expected: 0, }, { n: 1, max: 0, expected: 0, }, { n: 3, max: 2, expected: 1, }, { n: -1, max: 2, expected: 1, }, } for _, s := range scenarios { if s.expected != ModuloWithWrap(s.n, s.max) { t.Errorf("expected %d, got %d, for n: %d, max: %d", s.expected, ModuloWithWrap(s.n, s.max), s.n, s.max) } } } lazygit-0.50.0+ds1/pkg/utils/yaml_utils/000077500000000000000000000000001500612110400200335ustar00rootroot00000000000000lazygit-0.50.0+ds1/pkg/utils/yaml_utils/yaml_utils.go000066400000000000000000000135241500612110400225510ustar00rootroot00000000000000package yaml_utils import ( "bytes" "errors" "fmt" "gopkg.in/yaml.v3" ) // takes a yaml document in bytes, a path to a key, and a value to set. The value must be a scalar. func UpdateYamlValue(yamlBytes []byte, path []string, value string) ([]byte, error) { // Parse the YAML file. var node yaml.Node err := yaml.Unmarshal(yamlBytes, &node) if err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) } // Empty document: need to create the top-level map ourselves if len(node.Content) == 0 { node.Content = append(node.Content, &yaml.Node{ Kind: yaml.MappingNode, }) } body := node.Content[0] if body.Kind != yaml.MappingNode { return yamlBytes, errors.New("yaml document is not a dictionary") } if didChange, err := updateYamlNode(body, path, value); err != nil || !didChange { return yamlBytes, err } // Convert the updated YAML node back to YAML bytes. updatedYAMLBytes, err := YamlMarshal(body) if err != nil { return nil, fmt.Errorf("failed to convert YAML node to bytes: %w", err) } return updatedYAMLBytes, nil } // Recursive function to update the YAML node. func updateYamlNode(node *yaml.Node, path []string, value string) (bool, error) { if len(path) == 0 { if node.Kind != yaml.ScalarNode { return false, errors.New("yaml node is not a scalar") } if node.Value != value { node.Value = value return true, nil } return false, nil } if node.Kind != yaml.MappingNode { return false, errors.New("yaml node in path is not a dictionary") } key := path[0] if _, valueNode := lookupKey(node, key); valueNode != nil { return updateYamlNode(valueNode, path[1:], value) } // if the key doesn't exist, we'll add it // at end of path: add the new key, done if len(path) == 1 { node.Content = append(node.Content, &yaml.Node{ Kind: yaml.ScalarNode, Value: key, }, &yaml.Node{ Kind: yaml.ScalarNode, Value: value, }) return true, nil } // otherwise, create the missing intermediate node and continue newNode := &yaml.Node{ Kind: yaml.MappingNode, } node.Content = append(node.Content, &yaml.Node{ Kind: yaml.ScalarNode, Value: key, }, newNode) return updateYamlNode(newNode, path[1:], value) } func lookupKey(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) { for i := 0; i < len(node.Content)-1; i += 2 { if node.Content[i].Value == key { return node.Content[i], node.Content[i+1] } } return nil, nil } // Walks a yaml document from the root node to the specified path, and then applies the transformation to that node. // If the requested path is not defined in the document, no changes are made to the document. func TransformNode(rootNode *yaml.Node, path []string, transform func(node *yaml.Node) error) error { // Empty document: nothing to do. if len(rootNode.Content) == 0 { return nil } body := rootNode.Content[0] if err := transformNode(body, path, transform); err != nil { return err } return nil } // A recursive function to walk down the tree. See TransformNode for more details. func transformNode(node *yaml.Node, path []string, transform func(node *yaml.Node) error) error { if len(path) == 0 { return transform(node) } keyNode, valueNode := lookupKey(node, path[0]) if keyNode == nil { return nil } return transformNode(valueNode, path[1:], transform) } // Takes the root node of a yaml document, a path to a key, and a new name for the key. // Will rename the key to the new name if it exists, and do nothing otherwise. func RenameYamlKey(rootNode *yaml.Node, path []string, newKey string) error { // Empty document: nothing to do. if len(rootNode.Content) == 0 { return nil } body := rootNode.Content[0] if err := renameYamlKey(body, path, newKey); err != nil { return err } return nil } // Recursive function to rename the YAML key. func renameYamlKey(node *yaml.Node, path []string, newKey string) error { if node.Kind != yaml.MappingNode { return errors.New("yaml node in path is not a dictionary") } keyNode, valueNode := lookupKey(node, path[0]) if keyNode == nil { return nil } // end of path reached: rename key if len(path) == 1 { // Check that new key doesn't exist yet if newKeyNode, _ := lookupKey(node, newKey); newKeyNode != nil { return fmt.Errorf("new key `%s' already exists", newKey) } keyNode.Value = newKey return nil } return renameYamlKey(valueNode, path[1:], newKey) } // Traverses a yaml document, calling the callback function for each node. The // callback is expected to modify the node in place func Walk(rootNode *yaml.Node, callback func(node *yaml.Node, path string)) error { // Empty document: nothing to do. if len(rootNode.Content) == 0 { return nil } body := rootNode.Content[0] if err := walk(body, "", callback); err != nil { return err } return nil } func walk(node *yaml.Node, path string, callback func(*yaml.Node, string)) error { callback(node, path) switch node.Kind { case yaml.DocumentNode: return errors.New("Unexpected document node in the middle of a yaml tree") case yaml.MappingNode: for i := 0; i < len(node.Content); i += 2 { name := node.Content[i].Value childNode := node.Content[i+1] var childPath string if path == "" { childPath = name } else { childPath = fmt.Sprintf("%s.%s", path, name) } err := walk(childNode, childPath, callback) if err != nil { return err } } case yaml.SequenceNode: for i := 0; i < len(node.Content); i++ { childPath := fmt.Sprintf("%s[%d]", path, i) err := walk(node.Content[i], childPath, callback) if err != nil { return err } } case yaml.ScalarNode: // nothing to do case yaml.AliasNode: return errors.New("Alias nodes are not supported") } return nil } func YamlMarshal(node *yaml.Node) ([]byte, error) { var buffer bytes.Buffer encoder := yaml.NewEncoder(&buffer) encoder.SetIndent(2) err := encoder.Encode(node) return buffer.Bytes(), err } lazygit-0.50.0+ds1/pkg/utils/yaml_utils/yaml_utils_test.go000066400000000000000000000231251500612110400236060ustar00rootroot00000000000000package yaml_utils import ( "fmt" "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) func TestUpdateYamlValue(t *testing.T) { tests := []struct { name string in string path []string value string expectedOut string expectedErr string }{ { name: "update value", in: "foo: bar\n", path: []string{"foo"}, value: "baz", expectedOut: "foo: baz\n", expectedErr: "", }, { name: "add new key and value", in: "foo: bar\n", path: []string{"foo2"}, value: "baz", expectedOut: "foo: bar\nfoo2: baz\n", expectedErr: "", }, { name: "add new key and value when document was empty", in: "", path: []string{"foo"}, value: "bar", expectedOut: "foo: bar\n", expectedErr: "", }, { name: "preserve inline comment", in: "foo: bar # my comment\n", path: []string{"foo2"}, value: "baz", expectedOut: "foo: bar # my comment\nfoo2: baz\n", expectedErr: "", }, { name: "nested update", in: "foo:\n bar: baz\n", path: []string{"foo", "bar"}, value: "qux", expectedOut: "foo:\n bar: qux\n", expectedErr: "", }, { name: "nested where parents doesn't exist yet", in: "", path: []string{"foo", "bar", "baz"}, value: "qux", expectedOut: "foo:\n bar:\n baz: qux\n", expectedErr: "", }, { name: "don't rewrite file if value didn't change", in: "foo:\n bar: baz\n", path: []string{"foo", "bar"}, value: "baz", expectedOut: "foo:\n bar: baz\n", expectedErr: "", }, // Error cases { name: "existing document is not a dictionary", in: "42\n", path: []string{"foo"}, value: "bar", expectedOut: "42\n", expectedErr: "yaml document is not a dictionary", }, { name: "trying to update a note that is not a scalar", in: "foo: [1, 2, 3]\n", path: []string{"foo"}, value: "bar", expectedOut: "foo: [1, 2, 3]\n", expectedErr: "yaml node is not a scalar", }, { name: "not all path elements are dictionaries", in: "foo:\n bar: [1, 2, 3]\n", path: []string{"foo", "bar", "baz"}, value: "qux", expectedOut: "foo:\n bar: [1, 2, 3]\n", expectedErr: "yaml node in path is not a dictionary", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { out, actualErr := UpdateYamlValue([]byte(test.in), test.path, test.value) if test.expectedErr == "" { assert.NoError(t, actualErr) } else { assert.EqualError(t, actualErr, test.expectedErr) } assert.Equal(t, test.expectedOut, string(out)) }) } } func TestRenameYamlKey(t *testing.T) { tests := []struct { name string in string path []string newKey string expectedOut string expectedErr string }{ { name: "rename key", in: "foo: 5\n", path: []string{"foo"}, newKey: "bar", expectedOut: "bar: 5\n", expectedErr: "", }, { name: "rename key, nested", in: "foo:\n bar: 5\n", path: []string{"foo", "bar"}, newKey: "baz", expectedOut: "foo:\n baz: 5\n", expectedErr: "", }, { name: "rename non-scalar key", in: "foo:\n bar: 5\n", path: []string{"foo"}, newKey: "qux", expectedOut: "qux:\n bar: 5\n", expectedErr: "", }, { name: "don't rewrite file if value didn't change", in: "foo:\n bar: 5\n", path: []string{"nonExistingKey"}, newKey: "qux", expectedOut: "foo:\n bar: 5\n", expectedErr: "", }, // Error cases { name: "existing document is not a dictionary", in: "42\n", path: []string{"foo"}, newKey: "bar", expectedOut: "42\n", expectedErr: "yaml node in path is not a dictionary", }, { name: "not all path elements are dictionaries", in: "foo:\n bar: [1, 2, 3]\n", path: []string{"foo", "bar", "baz"}, newKey: "qux", expectedOut: "foo:\n bar: [1, 2, 3]\n", expectedErr: "yaml node in path is not a dictionary", }, { name: "new key exists", in: "foo: 5\nbar: 7\n", path: []string{"foo"}, newKey: "bar", expectedOut: "foo: 5\nbar: 7\n", expectedErr: "new key `bar' already exists", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { node := unmarshalForTest(t, test.in) actualErr := RenameYamlKey(&node, test.path, test.newKey) if test.expectedErr == "" { assert.NoError(t, actualErr) } else { assert.EqualError(t, actualErr, test.expectedErr) } out := marshalForTest(t, &node) assert.Equal(t, test.expectedOut, out) }) } } func TestWalk_paths(t *testing.T) { tests := []struct { name string document string expectedPaths []string }{ { name: "empty document", document: "", expectedPaths: []string{}, }, { name: "scalar", document: "x: 5", expectedPaths: []string{"", "x"}, // called with an empty path for the root node }, { name: "nested", document: "foo:\n x: 5", expectedPaths: []string{"", "foo", "foo.x"}, }, { name: "deeply nested", document: "foo:\n bar:\n baz: 5", expectedPaths: []string{"", "foo", "foo.bar", "foo.bar.baz"}, }, { name: "array", document: "foo:\n bar: [3, 7]", expectedPaths: []string{"", "foo", "foo.bar", "foo.bar[0]", "foo.bar[1]"}, }, { name: "nested arrays", document: "foo:\n bar: [[3, 7], [8, 9]]", expectedPaths: []string{"", "foo", "foo.bar", "foo.bar[0]", "foo.bar[0][0]", "foo.bar[0][1]", "foo.bar[1]", "foo.bar[1][0]", "foo.bar[1][1]"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { node := unmarshalForTest(t, test.document) paths := []string{} err := Walk(&node, func(node *yaml.Node, path string) { paths = append(paths, path) }) assert.NoError(t, err) assert.Equal(t, test.expectedPaths, paths) }) } } func TestWalk_inPlaceChanges(t *testing.T) { tests := []struct { name string in string callback func(node *yaml.Node, path string) expectedOut string }{ { name: "no change", in: "x: 5", callback: func(node *yaml.Node, path string) {}, }, { name: "change value", in: "x: 5\ny: 3", callback: func(node *yaml.Node, path string) { if path == "x" { node.Value = "7" } }, expectedOut: "x: 7\ny: 3\n", }, { name: "change nested value", in: "x:\n y: 5", callback: func(node *yaml.Node, path string) { if path == "x.y" { node.Value = "7" } }, expectedOut: "x:\n y: 7\n", }, { name: "change array value", in: "x:\n - y: 5", callback: func(node *yaml.Node, path string) { if path == "x[0].y" { node.Value = "7" } }, expectedOut: "x:\n - y: 7\n", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { node := unmarshalForTest(t, test.in) err := Walk(&node, test.callback) assert.NoError(t, err) if test.expectedOut == "" { unmodifiedOriginal := unmarshalForTest(t, test.in) assert.Equal(t, unmodifiedOriginal, node) } else { result := marshalForTest(t, &node) assert.Equal(t, test.expectedOut, result) } }) } } func TestTransformNode(t *testing.T) { transformIntValueToString := func(node *yaml.Node) error { if node.Kind == yaml.ScalarNode { if node.ShortTag() == "!!int" { node.Tag = "!!str" return nil } else if node.ShortTag() == "!!str" { // We have already transformed it, return nil } else { return fmt.Errorf("Node was of bad type") } } else { return fmt.Errorf("Node was not a scalar") } } tests := []struct { name string in string path []string transform func(node *yaml.Node) error expectedOut string }{ { name: "Path not present", in: "foo: 1", path: []string{"bar"}, transform: transformIntValueToString, }, { name: "Part of path present", in: ` foo: bar: 2`, path: []string{"foo", "baz"}, transform: transformIntValueToString, }, { name: "Successfully Transforms to string", in: ` foo: bar: 2`, path: []string{"foo", "bar"}, transform: transformIntValueToString, expectedOut: `foo: bar: "2" `, // Note the trailing newline changes because of how it re-marshalls }, { name: "Does nothing when already transformed", in: ` foo: bar: "2"`, path: []string{"foo", "bar"}, transform: transformIntValueToString, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { node := unmarshalForTest(t, test.in) err := TransformNode(&node, test.path, test.transform) if err != nil { t.Fatal(err) } if test.expectedOut == "" { unmodifiedOriginal := unmarshalForTest(t, test.in) assert.Equal(t, unmodifiedOriginal, node) } else { result := marshalForTest(t, &node) assert.Equal(t, test.expectedOut, result) } }) } } func unmarshalForTest(t *testing.T, input string) yaml.Node { t.Helper() var node yaml.Node err := yaml.Unmarshal([]byte(input), &node) if err != nil { t.Fatal(err) } return node } func marshalForTest(t *testing.T, node *yaml.Node) string { t.Helper() result, err := YamlMarshal(node) if err != nil { t.Fatal(err) } return string(result) } lazygit-0.50.0+ds1/schema/000077500000000000000000000000001500612110400151705ustar00rootroot00000000000000lazygit-0.50.0+ds1/schema/config.json000066400000000000000000001735221500612110400173420ustar00rootroot00000000000000{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/jesseduffield/lazygit/pkg/config/user-config", "$ref": "#/$defs/UserConfig", "$defs": { "CommitConfig": { "properties": { "signOff": { "type": "boolean", "description": "If true, pass '--signoff' flag when committing", "default": false }, "autoWrapCommitMessage": { "type": "boolean", "description": "Automatic WYSIWYG wrapping of the commit message as you type", "default": true }, "autoWrapWidth": { "type": "integer", "description": "If autoWrapCommitMessage is true, the width to wrap to", "default": 72 } }, "additionalProperties": false, "type": "object", "description": "Config relating to committing" }, "CommitLengthConfig": { "properties": { "show": { "type": "boolean", "description": "If true, show an indicator of commit message length", "default": true } }, "additionalProperties": false, "type": "object", "description": "Config relating to the commit length indicator" }, "CommitPrefixConfig": { "properties": { "pattern": { "type": "string", "description": "pattern to match on. E.g. for 'feature/AB-123' to match on the AB-123 use \"^\\\\w+\\\\/(\\\\w+-\\\\w+).*\"", "examples": [ "^\\w+\\/(\\w+-\\w+).*" ] }, "replace": { "type": "string", "description": "Replace directive. E.g. for 'feature/AB-123' to start the commit message with 'AB-123 ' use \"[$1] \"", "examples": [ "[$1]" ] } }, "additionalProperties": false, "type": "object" }, "CustomCommand": { "properties": { "key": { "type": "string", "description": "The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md" }, "commandMenu": { "items": { "$ref": "#/$defs/CustomCommand" }, "type": "array", "description": "Instead of defining a single custom command, create a menu of custom commands. Useful for grouping related commands together under a single keybinding, and for keeping them out of the global keybindings menu.\nWhen using this, all other fields except Key and Description are ignored and must be empty." }, "context": { "type": "string", "description": "The context in which to listen for the key. Valid values are: status, files, worktrees, localBranches, remotes, remoteBranches, tags, commits, reflogCommits, subCommits, commitFiles, stash, and global. Multiple contexts separated by comma are allowed; most useful for \"commits, subCommits\" or \"files, commitFiles\".", "examples": [ "status", "files", "worktrees", "localBranches", "remotes", "remoteBranches", "tags", "commits", "reflogCommits", "subCommits", "commitFiles", "stash", "global" ] }, "command": { "type": "string", "description": "The command to run (using Go template syntax for placeholder values)", "examples": [ "git fetch {{.Form.Remote}} {{.Form.Branch}} \u0026\u0026 git checkout FETCH_HEAD" ] }, "subprocess": { "type": "boolean", "description": "If true, run the command in a subprocess (e.g. if the command requires user input)" }, "prompts": { "items": { "$ref": "#/$defs/CustomCommandPrompt" }, "type": "array", "description": "A list of prompts that will request user input before running the final command" }, "loadingText": { "type": "string", "description": "Text to display while waiting for command to finish", "examples": [ "Loading..." ] }, "description": { "type": "string", "description": "Label for the custom command when displayed in the keybindings menu" }, "stream": { "type": "boolean", "description": "If true, stream the command's output to the Command Log panel" }, "showOutput": { "type": "boolean", "description": "If true, show the command's output in a popup within Lazygit" }, "outputTitle": { "type": "string", "description": "The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title." }, "after": { "$ref": "#/$defs/CustomCommandAfterHook", "description": "Actions to take after the command has completed" } }, "additionalProperties": false, "type": "object" }, "CustomCommandAfterHook": { "properties": { "checkForConflicts": { "type": "boolean" } }, "additionalProperties": false, "type": "object" }, "CustomCommandMenuOption": { "properties": { "name": { "type": "string", "description": "The first part of the label" }, "description": { "type": "string", "description": "The second part of the label" }, "value": { "type": "string", "minLength": 1, "description": "The value that will be used in the command", "examples": [ "feature" ] } }, "additionalProperties": false, "type": "object" }, "CustomCommandPrompt": { "properties": { "type": { "type": "string", "description": "One of: 'input' | 'menu' | 'confirm' | 'menuFromCommand'" }, "key": { "type": "string", "description": "Used to reference the entered value from within the custom command. E.g. a prompt with `key: 'Branch'` can be referred to as `{{.Form.Branch}}` in the command" }, "title": { "type": "string", "description": "The title to display in the popup panel" }, "initialValue": { "type": "string", "description": "The initial value to appear in the text box.\nOnly for input prompts." }, "suggestions": { "$ref": "#/$defs/CustomCommandSuggestions", "description": "Shows suggestions as the input is entered\nOnly for input prompts." }, "body": { "type": "string", "description": "The message of the confirmation prompt.\nOnly for confirm prompts.", "examples": [ "Are you sure you want to push to the remote?" ] }, "options": { "items": { "$ref": "#/$defs/CustomCommandMenuOption" }, "type": "array", "description": "Menu options.\nOnly for menu prompts." }, "command": { "type": "string", "description": "The command to run to generate menu options\nOnly for menuFromCommand prompts.", "examples": [ "git fetch {{.Form.Remote}} {{.Form.Branch}} \u0026\u0026 git checkout FETCH_HEAD" ] }, "filter": { "type": "string", "description": "The regexp to run specifying groups which are going to be kept from the command's output.\nOnly for menuFromCommand prompts.", "examples": [ ".*{{.SelectedRemote.Name }}/(?P\u003cbranch\u003e.*)" ] }, "valueFormat": { "type": "string", "description": "How to format matched groups from the filter to construct a menu item's value.\nOnly for menuFromCommand prompts.", "examples": [ "{{ .branch }}" ] }, "labelFormat": { "type": "string", "description": "Like valueFormat but for the labels. If `labelFormat` is not specified, `valueFormat` is shown instead.\nOnly for menuFromCommand prompts.", "examples": [ "{{ .branch | green }}" ] } }, "additionalProperties": false, "type": "object" }, "CustomCommandSuggestions": { "properties": { "preset": { "type": "string", "enum": [ "authors", "branches", "files", "refs", "remotes", "remoteBranches", "tags" ], "description": "Uses built-in logic to obtain the suggestions. One of 'authors' | 'branches' | 'files' | 'refs' | 'remotes' | 'remoteBranches' | 'tags'" }, "command": { "type": "string", "description": "Command to run such that each line in the output becomes a suggestion. Mutually exclusive with 'preset' field.", "examples": [ "git fetch {{.Form.Remote}} {{.Form.Branch}} \u0026\u0026 git checkout FETCH_HEAD" ] } }, "additionalProperties": false, "type": "object" }, "CustomIconsConfig": { "properties": { "filenames": { "additionalProperties": { "$ref": "#/$defs/IconProperties" }, "type": "object", "description": "Map of filenames to icon properties (icon and color)" }, "extensions": { "additionalProperties": { "$ref": "#/$defs/IconProperties" }, "type": "object", "description": "Map of file extensions (including the dot) to icon properties (icon and color)" } }, "additionalProperties": false, "type": "object", "description": "Custom icons for filenames and file extensions\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color" }, "GitConfig": { "properties": { "paging": { "$ref": "#/$defs/PagingConfig", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md" }, "commit": { "$ref": "#/$defs/CommitConfig", "description": "Config relating to committing" }, "merging": { "$ref": "#/$defs/MergingConfig", "description": "Config relating to merging" }, "mainBranches": { "items": { "type": "string" }, "type": "array", "uniqueItems": true, "description": "list of branches that are considered 'main' branches, used when displaying commits", "default": [ "master", "main" ] }, "skipHookPrefix": { "type": "string", "description": "Prefix to use when skipping hooks. E.g. if set to 'WIP', then pre-commit hooks will be skipped when the commit message starts with 'WIP'", "default": "WIP" }, "autoFetch": { "type": "boolean", "description": "If true, periodically fetch from remote", "default": true }, "autoRefresh": { "type": "boolean", "description": "If true, periodically refresh files and submodules", "default": true }, "autoForwardBranches": { "type": "string", "enum": [ "none", "onlyMainBranches", "allBranches" ], "description": "If not \"none\", lazygit will automatically forward branches to their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).\nPossible values: 'none' | 'onlyMainBranches' | 'allBranches'", "default": "onlyMainBranches" }, "fetchAll": { "type": "boolean", "description": "If true, pass the --all arg to git fetch", "default": true }, "autoStageResolvedConflicts": { "type": "boolean", "description": "If true, lazygit will automatically stage files that used to have merge\nconflicts but no longer do; and it will also ask you if you want to\ncontinue a merge or rebase if you've resolved all conflicts. If false, it\nwon't do either of these things.", "default": true }, "branchLogCmd": { "type": "string", "description": "Command used when displaying the current branch git log in the main window", "default": "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --" }, "allBranchesLogCmd": { "type": "string", "description": "Command used to display git log of all branches in the main window.\nDeprecated: Use `allBranchesLogCmds` instead.", "default": "git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium" }, "allBranchesLogCmds": { "items": { "type": "string" }, "type": "array", "description": "Commands used to display git log of all branches in the main window, they will be cycled in order of appearance (array of strings)" }, "overrideGpg": { "type": "boolean", "description": "If true, do not spawn a separate process when using GPG", "default": false }, "disableForcePushing": { "type": "boolean", "description": "If true, do not allow force pushes", "default": false }, "commitPrefix": { "items": { "$ref": "#/$defs/CommitPrefixConfig" }, "type": "array", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-commit-message-prefix" }, "commitPrefixes": { "additionalProperties": { "items": { "$ref": "#/$defs/CommitPrefixConfig" }, "type": "array" }, "type": "object", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-commit-message-prefix" }, "branchPrefix": { "type": "string", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-branch-name-prefix" }, "parseEmoji": { "type": "boolean", "description": "If true, parse emoji strings in commit messages e.g. render :rocket: as 🚀\n(This should really be under 'gui', not 'git')", "default": false }, "log": { "$ref": "#/$defs/LogConfig", "description": "Config for showing the log in the commits view" }, "truncateCopiedCommitHashesTo": { "type": "integer", "description": "When copying commit hashes to the clipboard, truncate them to this\nlength. Set to 40 to disable truncation.", "default": 12 } }, "additionalProperties": false, "type": "object", "description": "Config relating to git" }, "GuiConfig": { "properties": { "authorColors": { "additionalProperties": { "type": "string" }, "type": "object", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color" }, "branchColors": { "additionalProperties": { "type": "string" }, "type": "object", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color\nDeprecated: use branchColorPatterns instead" }, "branchColorPatterns": { "additionalProperties": { "type": "string" }, "type": "object", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color" }, "customIcons": { "$ref": "#/$defs/CustomIconsConfig", "description": "Custom icons for filenames and file extensions\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color" }, "scrollHeight": { "type": "integer", "minimum": 1, "description": "The number of lines you scroll by when scrolling the main window", "default": 2 }, "scrollPastBottom": { "type": "boolean", "description": "If true, allow scrolling past the bottom of the content in the main window", "default": true }, "scrollOffMargin": { "type": "integer", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#scroll-off-margin", "default": 2 }, "scrollOffBehavior": { "type": "string", "description": "One of: 'margin' (default) | 'jump'", "default": "margin" }, "tabWidth": { "type": "integer", "minimum": 1, "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.", "default": 4 }, "mouseEvents": { "type": "boolean", "description": "If true, capture mouse events.\nWhen mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS.", "default": true }, "skipDiscardChangeWarning": { "type": "boolean", "description": "If true, do not show a warning when discarding changes in the staging view.", "default": false }, "skipStashWarning": { "type": "boolean", "description": "If true, do not show warning when applying/popping the stash", "default": false }, "skipNoStagedFilesWarning": { "type": "boolean", "description": "If true, do not show a warning when attempting to commit without any staged files; instead stage all unstaged files.", "default": false }, "skipRewordInEditorWarning": { "type": "boolean", "description": "If true, do not show a warning when rewording a commit via an external editor", "default": false }, "sidePanelWidth": { "type": "number", "maximum": 1, "minimum": 0, "description": "Fraction of the total screen width to use for the left side section. You may want to pick a small number (e.g. 0.2) if you're using a narrow screen, so that you can see more of the main section.\nNumber from 0 to 1.0.", "default": 0.3333 }, "expandFocusedSidePanel": { "type": "boolean", "description": "If true, increase the height of the focused side window; creating an accordion effect.", "default": false }, "expandedSidePanelWeight": { "type": "integer", "description": "The weight of the expanded side panel, relative to the other panels. 2 means\ntwice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.", "default": 2 }, "mainPanelSplitMode": { "type": "string", "enum": [ "horizontal", "flexible", "vertical" ], "description": "Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split.\nOptions are:\n- 'horizontal': split the window horizontally\n- 'vertical': split the window vertically\n- 'flexible': (default) split the window horizontally if the window is wide enough, otherwise split vertically", "default": "flexible" }, "enlargedSideViewLocation": { "type": "string", "description": "How the window is split when in half screen mode (i.e. after hitting '+' once).\nPossible values:\n- 'left': split the window horizontally (side panel on the left, main view on the right)\n- 'top': split the window vertically (side panel on top, main view below)", "default": "left" }, "wrapLinesInStagingView": { "type": "boolean", "description": "If true, wrap lines in the staging view to the width of the view. This\nmakes it much easier to work with diffs that have long lines, e.g.\nparagraphs of markdown text.", "default": true }, "language": { "type": "string", "enum": [ "auto", "en", "zh-TW", "zh-CN", "pl", "nl", "ja", "ko", "ru" ], "description": "One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru'", "default": "auto" }, "timeFormat": { "type": "string", "description": "Format used when displaying time e.g. commit time.\nUses Go's time format syntax: https://pkg.go.dev/time#Time.Format", "default": "02 Jan 06" }, "shortTimeFormat": { "type": "string", "description": "Format used when displaying time if the time is less than 24 hours ago.\nUses Go's time format syntax: https://pkg.go.dev/time#Time.Format", "default": "3:04PM" }, "theme": { "$ref": "#/$defs/ThemeConfig", "description": "Config relating to colors and styles.\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes" }, "commitLength": { "$ref": "#/$defs/CommitLengthConfig", "description": "Config relating to the commit length indicator" }, "showListFooter": { "type": "boolean", "description": "If true, show the '5 of 20' footer at the bottom of list views", "default": true }, "showFileTree": { "type": "boolean", "description": "If true, display the files in the file views as a tree. If false, display the files as a flat list.\nThis can be toggled from within Lazygit with the '`' key, but that will not change the default.", "default": true }, "showNumstatInFilesView": { "type": "boolean", "description": "If true, show the number of lines changed per file in the Files view", "default": false }, "showRandomTip": { "type": "boolean", "description": "If true, show a random tip in the command log when Lazygit starts", "default": true }, "showCommandLog": { "type": "boolean", "description": "If true, show the command log", "default": true }, "showBottomLine": { "type": "boolean", "description": "If true, show the bottom line that contains keybinding info and useful buttons. If false, this line will be hidden except to display a loader for an in-progress action.", "default": true }, "showPanelJumps": { "type": "boolean", "description": "If true, show jump-to-window keybindings in window titles.", "default": true }, "showIcons": { "type": "boolean", "description": "Deprecated: use nerdFontsVersion instead", "default": false }, "nerdFontsVersion": { "type": "string", "enum": [ "2", "3", "" ], "description": "Nerd fonts version to use.\nOne of: '2' | '3' | empty string (default)\nIf empty, do not show icons." }, "showFileIcons": { "type": "boolean", "description": "If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.", "default": true }, "commitAuthorShortLength": { "type": "integer", "description": "Length of author name in (non-expanded) commits view. 2 means show initials only.", "default": 2 }, "commitAuthorLongLength": { "type": "integer", "description": "Length of author name in expanded commits view. 2 means show initials only.", "default": 17 }, "commitHashLength": { "type": "integer", "minimum": 0, "description": "Length of commit hash in commits view. 0 shows '*' if NF icons aren't on.", "default": 8 }, "showBranchCommitHash": { "type": "boolean", "description": "If true, show commit hashes alongside branch names in the branches view.", "default": false }, "showDivergenceFromBaseBranch": { "type": "string", "enum": [ "none", "onlyArrow", "arrowAndNumber" ], "description": "Whether to show the divergence from the base branch in the branches view.\nOne of: 'none' | 'onlyArrow' | 'arrowAndNumber'", "default": "none" }, "commandLogSize": { "type": "integer", "minimum": 0, "description": "Height of the command log view", "default": 8 }, "splitDiff": { "type": "string", "enum": [ "auto", "always" ], "description": "Whether to split the main window when viewing file changes.\nOne of: 'auto' | 'always'\nIf 'auto', only split the main window when a file has both staged and unstaged changes", "default": "auto" }, "screenMode": { "type": "string", "enum": [ "normal", "half", "full" ], "description": "Default size for focused window. Can be changed from within Lazygit with '+' and '_' (but this won't change the default).\nOne of: 'normal' (default) | 'half' | 'full'", "default": "normal" }, "border": { "type": "string", "enum": [ "single", "double", "rounded", "hidden" ], "description": "Window border style.\nOne of 'rounded' (default) | 'single' | 'double' | 'hidden'", "default": "rounded" }, "animateExplosion": { "type": "boolean", "description": "If true, show a seriously epic explosion animation when nuking the working tree.", "default": true }, "portraitMode": { "type": "string", "description": "Whether to stack UI components on top of each other.\nOne of 'auto' (default) | 'always' | 'never'", "default": "auto" }, "filterMode": { "type": "string", "enum": [ "substring", "fuzzy" ], "description": "How things are filtered when typing '/'.\nOne of 'substring' (default) | 'fuzzy'", "default": "substring" }, "spinner": { "$ref": "#/$defs/SpinnerConfig", "description": "Config relating to the spinner." }, "statusPanelView": { "type": "string", "enum": [ "dashboard", "allBranchesLog" ], "description": "Status panel view.\nOne of 'dashboard' (default) | 'allBranchesLog'", "default": "dashboard" }, "switchToFilesAfterStashPop": { "type": "boolean", "description": "If true, jump to the Files panel after popping a stash", "default": true }, "switchToFilesAfterStashApply": { "type": "boolean", "description": "If true, jump to the Files panel after applying a stash", "default": true }, "switchTabsWithPanelJumpKeys": { "type": "boolean", "description": "If true, when using the panel jump keys (default 1 through 5) and target panel is already active, go to next tab instead", "default": false } }, "additionalProperties": false, "type": "object", "description": "Config relating to the Lazygit UI" }, "IconProperties": { "properties": { "icon": { "type": "string" }, "color": { "type": "string" } }, "additionalProperties": false, "type": "object" }, "KeybindingAmendAttributeConfig": { "properties": { "resetAuthor": { "type": "string", "default": "a" }, "setAuthor": { "type": "string", "default": "A" }, "addCoAuthor": { "type": "string", "default": "c" } }, "additionalProperties": false, "type": "object" }, "KeybindingBranchesConfig": { "properties": { "createPullRequest": { "type": "string", "default": "o" }, "viewPullRequestOptions": { "type": "string", "default": "O" }, "copyPullRequestURL": { "type": "string", "default": "\u003cc-y\u003e" }, "checkoutBranchByName": { "type": "string", "default": "c" }, "forceCheckoutBranch": { "type": "string", "default": "F" }, "rebaseBranch": { "type": "string", "default": "r" }, "renameBranch": { "type": "string", "default": "R" }, "mergeIntoCurrentBranch": { "type": "string", "default": "M" }, "moveCommitsToNewBranch": { "type": "string", "default": "N" }, "viewGitFlowOptions": { "type": "string", "default": "i" }, "fastForward": { "type": "string", "default": "f" }, "createTag": { "type": "string", "default": "T" }, "pushTag": { "type": "string", "default": "P" }, "setUpstream": { "type": "string", "default": "u" }, "fetchRemote": { "type": "string", "default": "f" }, "sortOrder": { "type": "string", "default": "s" } }, "additionalProperties": false, "type": "object" }, "KeybindingCommitFilesConfig": { "properties": { "checkoutCommitFile": { "type": "string", "default": "c" } }, "additionalProperties": false, "type": "object" }, "KeybindingCommitMessageConfig": { "properties": { "commitMenu": { "type": "string", "default": "\u003cc-o\u003e" } }, "additionalProperties": false, "type": "object" }, "KeybindingCommitsConfig": { "properties": { "squashDown": { "type": "string", "default": "s" }, "renameCommit": { "type": "string", "default": "r" }, "renameCommitWithEditor": { "type": "string", "default": "R" }, "viewResetOptions": { "type": "string", "default": "g" }, "markCommitAsFixup": { "type": "string", "default": "f" }, "createFixupCommit": { "type": "string", "default": "F" }, "squashAboveCommits": { "type": "string", "default": "S" }, "moveDownCommit": { "type": "string", "default": "\u003cc-j\u003e" }, "moveUpCommit": { "type": "string", "default": "\u003cc-k\u003e" }, "amendToCommit": { "type": "string", "default": "A" }, "resetCommitAuthor": { "type": "string", "default": "a" }, "pickCommit": { "type": "string", "default": "p" }, "revertCommit": { "type": "string", "default": "t" }, "cherryPickCopy": { "type": "string", "default": "C" }, "pasteCommits": { "type": "string", "default": "V" }, "markCommitAsBaseForRebase": { "type": "string", "default": "B" }, "tagCommit": { "type": "string", "default": "T" }, "checkoutCommit": { "type": "string", "default": "\u003cspace\u003e" }, "resetCherryPick": { "type": "string", "default": "\u003cc-R\u003e" }, "copyCommitAttributeToClipboard": { "type": "string", "default": "y" }, "openLogMenu": { "type": "string", "default": "\u003cc-l\u003e" }, "openInBrowser": { "type": "string", "default": "o" }, "viewBisectOptions": { "type": "string", "default": "b" }, "startInteractiveRebase": { "type": "string", "default": "i" }, "selectCommitsOfCurrentBranch": { "type": "string", "default": "*" } }, "additionalProperties": false, "type": "object" }, "KeybindingConfig": { "properties": { "universal": { "$ref": "#/$defs/KeybindingUniversalConfig" }, "status": { "$ref": "#/$defs/KeybindingStatusConfig" }, "files": { "$ref": "#/$defs/KeybindingFilesConfig" }, "branches": { "$ref": "#/$defs/KeybindingBranchesConfig" }, "worktrees": { "$ref": "#/$defs/KeybindingWorktreesConfig" }, "commits": { "$ref": "#/$defs/KeybindingCommitsConfig" }, "amendAttribute": { "$ref": "#/$defs/KeybindingAmendAttributeConfig" }, "stash": { "$ref": "#/$defs/KeybindingStashConfig" }, "commitFiles": { "$ref": "#/$defs/KeybindingCommitFilesConfig" }, "main": { "$ref": "#/$defs/KeybindingMainConfig" }, "submodules": { "$ref": "#/$defs/KeybindingSubmodulesConfig" }, "commitMessage": { "$ref": "#/$defs/KeybindingCommitMessageConfig" } }, "additionalProperties": false, "type": "object", "description": "Keybindings" }, "KeybindingFilesConfig": { "properties": { "commitChanges": { "type": "string", "default": "c" }, "commitChangesWithoutHook": { "type": "string", "default": "w" }, "amendLastCommit": { "type": "string", "default": "A" }, "commitChangesWithEditor": { "type": "string", "default": "C" }, "findBaseCommitForFixup": { "type": "string", "default": "\u003cc-f\u003e" }, "confirmDiscard": { "type": "string", "default": "x" }, "ignoreFile": { "type": "string", "default": "i" }, "refreshFiles": { "type": "string", "default": "r" }, "stashAllChanges": { "type": "string", "default": "s" }, "viewStashOptions": { "type": "string", "default": "S" }, "toggleStagedAll": { "type": "string", "default": "a" }, "viewResetOptions": { "type": "string", "default": "D" }, "fetch": { "type": "string", "default": "f" }, "toggleTreeView": { "type": "string", "default": "`" }, "openMergeTool": { "type": "string", "default": "M" }, "openStatusFilter": { "type": "string", "default": "\u003cc-b\u003e" }, "copyFileInfoToClipboard": { "type": "string", "default": "y" }, "collapseAll": { "type": "string", "default": "-" }, "expandAll": { "type": "string", "default": "=" } }, "additionalProperties": false, "type": "object" }, "KeybindingMainConfig": { "properties": { "toggleSelectHunk": { "type": "string", "default": "a" }, "pickBothHunks": { "type": "string", "default": "b" }, "editSelectHunk": { "type": "string", "default": "E" } }, "additionalProperties": false, "type": "object" }, "KeybindingStashConfig": { "properties": { "popStash": { "type": "string", "default": "g" }, "renameStash": { "type": "string", "default": "r" } }, "additionalProperties": false, "type": "object" }, "KeybindingStatusConfig": { "properties": { "checkForUpdate": { "type": "string", "default": "u" }, "recentRepos": { "type": "string", "default": "\u003center\u003e" }, "allBranchesLogGraph": { "type": "string", "default": "a" } }, "additionalProperties": false, "type": "object" }, "KeybindingSubmodulesConfig": { "properties": { "init": { "type": "string", "default": "i" }, "update": { "type": "string", "default": "u" }, "bulkMenu": { "type": "string", "default": "b" } }, "additionalProperties": false, "type": "object" }, "KeybindingUniversalConfig": { "properties": { "quit": { "type": "string", "default": "q" }, "quit-alt1": { "type": "string", "default": "\u003cc-c\u003e" }, "return": { "type": "string", "default": "\u003cesc\u003e" }, "quitWithoutChangingDirectory": { "type": "string", "default": "Q" }, "togglePanel": { "type": "string", "default": "\u003ctab\u003e" }, "prevItem": { "type": "string", "default": "\u003cup\u003e" }, "nextItem": { "type": "string", "default": "\u003cdown\u003e" }, "prevItem-alt": { "type": "string", "default": "k" }, "nextItem-alt": { "type": "string", "default": "j" }, "prevPage": { "type": "string", "default": "," }, "nextPage": { "type": "string", "default": "." }, "scrollLeft": { "type": "string", "default": "H" }, "scrollRight": { "type": "string", "default": "L" }, "gotoTop": { "type": "string", "default": "\u003c" }, "gotoBottom": { "type": "string", "default": "\u003e" }, "gotoTop-alt": { "type": "string", "default": "\u003chome\u003e" }, "gotoBottom-alt": { "type": "string", "default": "\u003cend\u003e" }, "toggleRangeSelect": { "type": "string", "default": "v" }, "rangeSelectDown": { "type": "string", "default": "\u003cs-down\u003e" }, "rangeSelectUp": { "type": "string", "default": "\u003cs-up\u003e" }, "prevBlock": { "type": "string", "default": "\u003cleft\u003e" }, "nextBlock": { "type": "string", "default": "\u003cright\u003e" }, "prevBlock-alt": { "type": "string", "default": "h" }, "nextBlock-alt": { "type": "string", "default": "l" }, "nextBlock-alt2": { "type": "string", "default": "\u003ctab\u003e" }, "prevBlock-alt2": { "type": "string", "default": "\u003cbacktab\u003e" }, "jumpToBlock": { "items": { "type": "string" }, "type": "array", "default": [ "1", "2", "3", "4", "5" ] }, "focusMainView": { "type": "string", "default": "0" }, "nextMatch": { "type": "string", "default": "n" }, "prevMatch": { "type": "string", "default": "N" }, "startSearch": { "type": "string", "default": "/" }, "optionMenu": { "type": "string", "default": "\u003cdisabled\u003e" }, "optionMenu-alt1": { "type": "string", "default": "?" }, "select": { "type": "string", "default": "\u003cspace\u003e" }, "goInto": { "type": "string", "default": "\u003center\u003e" }, "confirm": { "type": "string", "default": "\u003center\u003e" }, "confirmInEditor": { "type": "string", "default": "\u003ca-enter\u003e" }, "remove": { "type": "string", "default": "d" }, "new": { "type": "string", "default": "n" }, "edit": { "type": "string", "default": "e" }, "openFile": { "type": "string", "default": "o" }, "scrollUpMain": { "type": "string", "default": "\u003cpgup\u003e" }, "scrollDownMain": { "type": "string", "default": "\u003cpgdown\u003e" }, "scrollUpMain-alt1": { "type": "string", "default": "K" }, "scrollDownMain-alt1": { "type": "string", "default": "J" }, "scrollUpMain-alt2": { "type": "string", "default": "\u003cc-u\u003e" }, "scrollDownMain-alt2": { "type": "string", "default": "\u003cc-d\u003e" }, "executeShellCommand": { "type": "string", "default": ":" }, "createRebaseOptionsMenu": { "type": "string", "default": "m" }, "pushFiles": { "type": "string", "description": "'Files' appended for legacy reasons", "default": "P" }, "pullFiles": { "type": "string", "description": "'Files' appended for legacy reasons", "default": "p" }, "refresh": { "type": "string", "default": "R" }, "createPatchOptionsMenu": { "type": "string", "default": "\u003cc-p\u003e" }, "nextTab": { "type": "string", "default": "]" }, "prevTab": { "type": "string", "default": "[" }, "nextScreenMode": { "type": "string", "default": "+" }, "prevScreenMode": { "type": "string", "default": "_" }, "undo": { "type": "string", "default": "z" }, "redo": { "type": "string", "default": "\u003cc-z\u003e" }, "filteringMenu": { "type": "string", "default": "\u003cc-s\u003e" }, "diffingMenu": { "type": "string", "default": "W" }, "diffingMenu-alt": { "type": "string", "default": "\u003cc-e\u003e" }, "copyToClipboard": { "type": "string", "default": "\u003cc-o\u003e" }, "openRecentRepos": { "type": "string", "default": "\u003cc-r\u003e" }, "submitEditorText": { "type": "string", "default": "\u003center\u003e" }, "extrasMenu": { "type": "string", "default": "@" }, "toggleWhitespaceInDiffView": { "type": "string", "default": "\u003cc-w\u003e" }, "increaseContextInDiffView": { "type": "string", "default": "}" }, "decreaseContextInDiffView": { "type": "string", "default": "{" }, "increaseRenameSimilarityThreshold": { "type": "string", "default": ")" }, "decreaseRenameSimilarityThreshold": { "type": "string", "default": "(" }, "openDiffTool": { "type": "string", "default": "\u003cc-t\u003e" } }, "additionalProperties": false, "type": "object" }, "KeybindingWorktreesConfig": { "properties": { "viewWorktreeOptions": { "type": "string", "default": "w" } }, "additionalProperties": false, "type": "object" }, "LogConfig": { "properties": { "order": { "type": "string", "enum": [ "date-order", "author-date-order", "topo-order", "default" ], "description": "One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default'\n'topo-order' makes it easier to read the git log graph, but commits may not\nappear chronologically. See https://git-scm.com/docs/\n\nDeprecated: Configure this with `Log menu -\u003e Commit sort order` (\u003cc-l\u003e in the commits window by default).", "default": "topo-order" }, "showGraph": { "type": "string", "enum": [ "always", "never", "when-maximised" ], "description": "This determines whether the git graph is rendered in the commits panel\nOne of 'always' | 'never' | 'when-maximised'\n\nDeprecated: Configure this with `Log menu -\u003e Show git graph` (\u003cc-l\u003e in the commits window by default).", "default": "always" }, "showWholeGraph": { "type": "boolean", "description": "displays the whole git graph by default in the commits view (equivalent to passing the `--all` argument to `git log`)", "default": false } }, "additionalProperties": false, "type": "object", "description": "Config for showing the log in the commits view" }, "MergingConfig": { "properties": { "manualCommit": { "type": "boolean", "description": "If true, run merges in a subprocess so that if a commit message is required, Lazygit will not hang\nOnly applicable to unix users.", "default": false }, "args": { "type": "string", "description": "Extra args passed to `git merge`, e.g. --no-ff", "examples": [ "--no-ff" ] }, "squashMergeMessage": { "type": "string", "description": "The commit message to use for a squash merge commit. Can contain \"{{selectedRef}}\" and \"{{currentBranch}}\" placeholders.", "default": "Squash merge {{selectedRef}} into {{currentBranch}}" } }, "additionalProperties": false, "type": "object", "description": "Config relating to merging" }, "OSConfig": { "properties": { "edit": { "type": "string", "description": "Command for editing a file. Should contain \"{{filename}}\"." }, "editAtLine": { "type": "string", "description": "Command for editing a file at a given line number. Should contain\n\"{{filename}}\", and may optionally contain \"{{line}}\"." }, "editAtLineAndWait": { "type": "string", "description": "Same as EditAtLine, except that the command needs to wait until the\nwindow is closed." }, "editInTerminal": { "type": "boolean", "description": "Whether lazygit suspends until an edit process returns" }, "openDirInEditor": { "type": "string", "description": "For opening a directory in an editor" }, "editPreset": { "type": "string", "description": "A built-in preset that sets all of the above settings. Supported presets\nare defined in the getPreset function in editor_presets.go.", "examples": [ "vim", "nvim", "emacs", "nano", "vscode", "sublime", "kakoune", "helix", "xcode", "zed", "acme" ] }, "open": { "type": "string", "description": "Command for opening a file, as if the file is double-clicked. Should\ncontain \"{{filename}}\", but doesn't support \"{{line}}\"." }, "openLink": { "type": "string", "description": "Command for opening a link. Should contain \"{{link}}\"." }, "copyToClipboardCmd": { "type": "string", "description": "CopyToClipboardCmd is the command for copying to clipboard.\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard" }, "readFromClipboardCmd": { "type": "string", "description": "ReadFromClipboardCmd is the command for reading the clipboard.\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard" }, "shellFunctionsFile": { "type": "string", "description": "A shell startup file containing shell aliases or shell functions. This will be sourced before running any shell commands, so that shell functions are available in the `:` command prompt or even in custom commands.\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#using-aliases-or-functions-in-shell-commands" }, "editCommand": { "type": "string", "description": "EditCommand is the command for editing a file.\nDeprecated: use Edit instead. Note that semantics are different:\nEditCommand is just the command itself, whereas Edit contains a\n\"{{filename}}\" variable." }, "editCommandTemplate": { "type": "string", "description": "EditCommandTemplate is the command template for editing a file\nDeprecated: use EditAtLine instead." }, "openCommand": { "type": "string", "description": "OpenCommand is the command for opening a file\nDeprecated: use Open instead." }, "openLinkCommand": { "type": "string", "description": "OpenLinkCommand is the command for opening a link\nDeprecated: use OpenLink instead." } }, "additionalProperties": false, "type": "object", "description": "Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc" }, "PagingConfig": { "properties": { "colorArg": { "type": "string", "enum": [ "always", "never" ], "description": "Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never'", "default": "always" }, "pager": { "type": "string", "description": "e.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat -s --wrap --width={{columnWidth}}", "default": "", "examples": [ "delta --dark --paging=never", "diff-so-fancy", "ydiff -p cat -s --wrap --width={{columnWidth}}" ] }, "useConfig": { "type": "boolean", "description": "If true, Lazygit will use whatever pager is specified in `$GIT_PAGER`, `$PAGER`, or your *git config*. If the pager ends with something like ` | less` we will strip that part out, because less doesn't play nice with our rendering approach. If the custom pager uses less under the hood, that will also break rendering (hence the `--paging=never` flag for the `delta` pager).", "default": false }, "externalDiffCommand": { "type": "string", "description": "e.g. 'difft --color=always'" } }, "additionalProperties": false, "type": "object", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md" }, "RefresherConfig": { "properties": { "refreshInterval": { "type": "integer", "minimum": 0, "description": "File/submodule refresh interval in seconds.\nAuto-refresh can be disabled via option 'git.autoRefresh'.", "default": 10 }, "fetchInterval": { "type": "integer", "minimum": 0, "description": "Re-fetch interval in seconds.\nAuto-fetch can be disabled via option 'git.autoFetch'.", "default": 60 } }, "additionalProperties": false, "type": "object", "description": "Background refreshes" }, "SpinnerConfig": { "properties": { "frames": { "items": { "type": "string" }, "type": "array", "description": "The frames of the spinner animation.", "default": [ "|", "/", "-", "\\" ] }, "rate": { "type": "integer", "minimum": 1, "description": "The \"speed\" of the spinner in milliseconds.", "default": 50 } }, "additionalProperties": false, "type": "object", "description": "Config relating to the spinner." }, "ThemeConfig": { "properties": { "activeBorderColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Border color of focused window", "default": [ "green", "bold" ] }, "inactiveBorderColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Border color of non-focused windows", "default": [ "default" ] }, "searchingActiveBorderColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Border color of focused window when searching in that window", "default": [ "cyan", "bold" ] }, "optionsTextColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Color of keybindings help text in the bottom line", "default": [ "blue" ] }, "selectedLineBgColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Background color of selected line.\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line", "default": [ "blue" ] }, "inactiveViewSelectedLineBgColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Background color of selected line when view doesn't have focus.", "default": [ "bold" ] }, "cherryPickedCommitFgColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Foreground color of copied commit", "default": [ "blue" ] }, "cherryPickedCommitBgColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Background color of copied commit", "default": [ "cyan" ] }, "markedBaseCommitFgColor": { "items": { "type": "string" }, "type": "array", "description": "Foreground color of marked base commit (for rebase)", "default": [ "blue" ] }, "markedBaseCommitBgColor": { "items": { "type": "string" }, "type": "array", "description": "Background color of marked base commit (for rebase)", "default": [ "yellow" ] }, "unstagedChangesColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Color for file with unstaged changes", "default": [ "red" ] }, "defaultFgColor": { "items": { "type": "string" }, "type": "array", "minItems": 1, "uniqueItems": true, "description": "Default text color", "default": [ "default" ] } }, "additionalProperties": false, "type": "object", "description": "Config relating to colors and styles.\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes" }, "UpdateConfig": { "properties": { "method": { "type": "string", "enum": [ "prompt", "background", "never" ], "description": "One of: 'prompt' (default) | 'background' | 'never'", "default": "prompt" }, "days": { "type": "integer", "minimum": 0, "description": "Period in days between update checks", "default": 14 } }, "additionalProperties": false, "type": "object", "description": "Periodic update checks" }, "UserConfig": { "properties": { "gui": { "$ref": "#/$defs/GuiConfig", "description": "Config relating to the Lazygit UI" }, "git": { "$ref": "#/$defs/GitConfig", "description": "Config relating to git" }, "update": { "$ref": "#/$defs/UpdateConfig", "description": "Periodic update checks" }, "refresher": { "$ref": "#/$defs/RefresherConfig", "description": "Background refreshes" }, "confirmOnQuit": { "type": "boolean", "description": "If true, show a confirmation popup before quitting Lazygit", "default": false }, "quitOnTopLevelReturn": { "type": "boolean", "description": "If true, exit Lazygit when the user presses escape in a context where there is nothing to cancel/close", "default": false }, "os": { "$ref": "#/$defs/OSConfig", "description": "Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc" }, "disableStartupPopups": { "type": "boolean", "description": "If true, don't display introductory popups upon opening Lazygit.", "default": false }, "customCommands": { "items": { "$ref": "#/$defs/CustomCommand" }, "type": "array", "uniqueItems": true, "description": "User-configured commands that can be invoked from within Lazygit\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md" }, "services": { "additionalProperties": { "type": "string" }, "type": "object", "description": "See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-pull-request-urls" }, "notARepository": { "type": "string", "enum": [ "prompt", "create", "skip", "quit" ], "description": "What to do when opening Lazygit outside of a git repo.\n- 'prompt': (default) ask whether to initialize a new repo or open in the most recent repo\n- 'create': initialize a new repo\n- 'skip': open most recent repo\n- 'quit': exit Lazygit", "default": "prompt" }, "promptToReturnFromSubprocess": { "type": "boolean", "description": "If true, display a confirmation when subprocess terminates. This allows you to view the output of the subprocess before returning to Lazygit.", "default": true }, "keybinding": { "$ref": "#/$defs/KeybindingConfig", "description": "Keybindings" } }, "additionalProperties": false, "type": "object" } } } lazygit-0.50.0+ds1/scripts/000077500000000000000000000000001500612110400154175ustar00rootroot00000000000000lazygit-0.50.0+ds1/scripts/bisect.sh000077500000000000000000000015671500612110400172400ustar00rootroot00000000000000#!/bin/sh # How to use: # 1) find a commit that is working fine. # 2) Create an integration test capturing the fact that it works (Don't commit it). See https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md # 3) checkout the commit that's known to be failing # 4) run this script supplying the commit hash / tag name that works and the name of the newly created test # usage: scripts/bisect.sh # e.g. scripts/bisect.sh v0.32.1 mergeConflictsResolvedExternally # It's assumed that the current commit (i.e. HEAD) is broken. if [[ $# -ne 3 ]] ; then echo 'Usage: scripts/bisect.sh ' exit 1 fi git bisect start $1 $2 git bisect run sh -c "(go build -o /dev/null || exit 125) && go test ./pkg/gui -run /$3" git bisect reset lazygit-0.50.0+ds1/scripts/bump_gocui.sh000077500000000000000000000006411500612110400201100ustar00rootroot00000000000000#!/bin/sh # Go's proxy servers are not very up-to-date so that's why we use `GOPROXY=direct` # We specify the `master` branch to avoid the default behaviour of looking for a semver tag. GOPROXY=direct go get -u github.com/jesseduffield/gocui@master && go mod vendor && go mod tidy # Note to self if you ever want to fork a repo be sure to use this same approach: it's important to use the branch name (e.g. master) lazygit-0.50.0+ds1/scripts/bump_lazycore.sh000077500000000000000000000006321500612110400206320ustar00rootroot00000000000000# Go's proxy servers are not very up-to-date so that's why we use `GOPROXY=direct` # We specify the `awesome` branch to avoid the default behaviour of looking for a semver tag. GOPROXY=direct go get -u github.com/jesseduffield/lazycore@master && go mod vendor && go mod tidy # Note to self if you ever want to fork a repo be sure to use this same approach: it's important to use the branch name (e.g. master) lazygit-0.50.0+ds1/scripts/bump_modules.sh000077500000000000000000000000651500612110400204520ustar00rootroot00000000000000#!/bin/sh GO111MODULE=on mv go.mod /tmp/ go mod initlazygit-0.50.0+ds1/scripts/check_filenames.sh000077500000000000000000000007751500612110400210670ustar00rootroot00000000000000#!/bin/bash # Find all Go files in the project directory and its subdirectories, except in the vendor directory for file in $(find . -name "*.go" -not -path "./vendor/*"); do # Check if the file name contains uppercase letters if [[ "$file" =~ [A-Z] ]]; then echo "Error: $file contains uppercase letters. All Go files in the project (excluding vendor directory) must use snake_case" exit 1 fi done echo "All Go files in the project (excluding vendor directory) use lowercase letters" exit 0 lazygit-0.50.0+ds1/scripts/check_for_fixups.sh000077500000000000000000000006201500612110400212750ustar00rootroot00000000000000#!/bin/sh # We will have only done a shallow clone, so the git log will consist only of # commits on the current PR commits=$(git log --grep='^fixup!' --grep='^squash!' --grep='^amend!' --grep='^[^\n]*WIP' --grep='^[^\n]*DROPME' --format="%h %s") if [ -z "$commits" ]; then echo "No fixup commits found." exit 0 else echo "Fixup or WIP commits found:" echo "$commits" exit 1 fi lazygit-0.50.0+ds1/scripts/record_demo.sh000077500000000000000000000000441500612110400202360ustar00rootroot00000000000000#!/bin/sh demo/record_demo.sh "$@" lazygit-0.50.0+ds1/scripts/run_integration_tests.sh000077500000000000000000000034331500612110400224120ustar00rootroot00000000000000#!/bin/sh echo "Running integration tests with $(git --version)" # This is ugly, but older versions of git don't support the GIT_CONFIG_GLOBAL # env var; the only way to run tests for these old versions is to copy our test # config file to the actual global location. Move an existing file out of the # way so that we can restore it at the end. if test -f ~/.gitconfig; then mv ~/.gitconfig ~/.gitconfig.lazygit.bak fi cp test/global_git_config ~/.gitconfig # if the LAZYGIT_GOCOVERDIR env var is set, we'll capture code coverage data if [ -n "$LAZYGIT_GOCOVERDIR" ]; then # Go expects us to either be running the test binary directly or running `go test`, but because # we're doing both and because we want to combine coverage data for both, we need to be a little # hacky. To capture the coverage data for the test runner we pass the test.gocoverdir positional # arg, but if we do that then the GOCOVERDIR env var (which you typically pass to the test binary) will be overwritten by the test runner. So we're passing LAZYGIT_COCOVERDIR instead # and then internally passing that to the test binary as GOCOVERDIR. go test -cover -coverpkg=github.com/jesseduffield/lazygit/pkg/... pkg/integration/clients/*.go -args -test.gocoverdir="/tmp/code_coverage" EXITCODE=$? # We're merging the coverage data for the sake of having fewer artefacts to upload. # We can't merge inline so we're merging to a tmp dir then moving back to the original. mkdir -p /tmp/code_coverage_merged go tool covdata merge -i=/tmp/code_coverage -o=/tmp/code_coverage_merged rm -rf /tmp/code_coverage mv /tmp/code_coverage_merged /tmp/code_coverage else go test pkg/integration/clients/*.go EXITCODE=$? fi if test -f ~/.gitconfig.lazygit.bak; then mv ~/.gitconfig.lazygit.bak ~/.gitconfig fi exit $EXITCODE lazygit-0.50.0+ds1/scripts/update_language_files.sh000077500000000000000000000021101500612110400222570ustar00rootroot00000000000000#!/bin/sh set -e # Since I couldn't get crowdin-cli to work yet, I'm doing things a bit more # manually for now. The process is as follows: # # 1. Download the translations from Crowdin as a zip file # 2. Unzip the file # 3. Run this script with the path to the unzipped directory as an argument # # Requires jq (1.7 or later): https://github.com/jqlang/jq if [ "$#" -ne 1 ]; then echo "Usage: $0 " exit 2 fi download_dir="$1" # The Portuguese translation is named pt-PT, but we want to use pt instead (it # is used both for Brasilian and European Portuguese). I couldn't figure out how # to change this in Crowdin, so we'll do it here. [ -d "$download_dir/pt-PT" ] && mv "$download_dir/pt-PT" "$download_dir/pt" for d in "$download_dir"/* do # We need to remove empty strings from the JSON files; those are the ones # that haven't been translated yet. Crowdin has an option to skip these when # exporting, but unfortunately it doesn't work for json files. jq 'del(..|select(. == ""))' < "$d/en.json" > pkg/i18n/translations/$(basename "$d").json done lazygit-0.50.0+ds1/test/000077500000000000000000000000001500612110400147075ustar00rootroot00000000000000lazygit-0.50.0+ds1/test/.gitconfig000077700000000000000000000000001500612110400222272global_git_configustar00rootroot00000000000000lazygit-0.50.0+ds1/test/README.md000066400000000000000000000002751500612110400161720ustar00rootroot00000000000000This directory contains some files used by out integration tests. The tests themselves live in [/pkg/integration/](/pkg/integration/). See [here](/pkg/integration/README.md) for more info lazygit-0.50.0+ds1/test/default_test_config/000077500000000000000000000000001500612110400207175ustar00rootroot00000000000000lazygit-0.50.0+ds1/test/default_test_config/config.yml000066400000000000000000000015231500612110400227100ustar00rootroot00000000000000# This config is used in our integration tests. If we want to modify this for a specific test, you can do so in the SetupConfig function disableStartupPopups: true promptToReturnFromSubprocess: false gui: theme: activeBorderColor: - green - bold inactiveBorderColor: - black # Not important in tests but it creates clutter in demos showRandomTip: false animateExplosion: false # takes too long git: # We don't want to run any periodic background git commands because it'll introduce race conditions and flakiness. # If we need to refresh something from within the test (which should only really happen if we've invoked a # shell command in the background) we should have the user press shift+R to refresh. # TODO: add tests which explicitly test auto-refresh functionality autoRefresh: false autoFetch: false lazygit-0.50.0+ds1/test/files/000077500000000000000000000000001500612110400160115ustar00rootroot00000000000000lazygit-0.50.0+ds1/test/files/pre-push000077500000000000000000000012131500612110400174770ustar00rootroot00000000000000#!/bin/bash # test pre-push hook for testing the lazygit credentials view # # to enable, use: # chmod +x .git/hooks/pre-push # # this will hang if you're using git from the command line, so only enable this # when you are testing the credentials view in lazygit exec < /dev/tty echo -n "Username for 'github': " read username echo -n "Password for 'github': " # this will print the password to the log view but real git won't do that. # We could use read -s but that's not POSIX compliant. read password if [ "$username" = "username" -a "$password" = "password" ]; then echo "success" exit 0 fi >&2 echo "incorrect username/password" exit 1 lazygit-0.50.0+ds1/test/global_git_config000066400000000000000000000005051500612110400202620ustar00rootroot00000000000000# This is the global git config we use for all our integration tests [user] name = CI email = CI@example.com [protocol "file"] # see https://vielmetti.typepad.com/logbook/2022/10/git-security-fixes-lead-to-fatal-transport-file-not-allowed-error-in-ci-systems-cve-2022-39253.html allow = always [commit] gpgSign = false