Compare commits

..

2 Commits

Author SHA1 Message Date
Chris Allen Lane
e467740c87 Merge pull request #738 from sv222/update-installing
Update installation instructions for Windows in INSTALLING.md
2023-12-13 12:20:24 -05:00
Stanislav
c52a9426b1 Update installation instructions for Windows in INSTALLING.md 2023-09-29 20:17:42 +02:00
1776 changed files with 57862 additions and 133059 deletions

3
.gitattributes vendored
View File

@@ -1,3 +0,0 @@
# Force LF line endings for mock/test data files to ensure consistent
# behavior across platforms (Windows git autocrlf converts to CRLF otherwise)
mocks/** text eol=lf

View File

@@ -3,5 +3,9 @@ updates:
- package-ecosystem: gomod
directory: "/"
schedule:
interval: weekly
interval: daily
open-pull-requests-limit: 10
ignore:
- dependency-name: github.com/alecthomas/chroma
versions:
- 0.9.1

View File

@@ -1,38 +1,46 @@
---
name: CI
name: Go
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
lint:
runs-on: ubuntu-latest
# TODO: is it possible to DRY out these jobs? Aside from `runs-on`, they are
# identical.
# See: https://github.com/actions/runner/issues/1182
build-linux:
runs-on: [ubuntu-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: stable
- name: Install revive
run: go install github.com/mgechev/revive@latest
- name: Lint
run: revive -exclude vendor/... ./...
- name: Vet
run: go vet ./...
- name: Check formatting
run: test -z "$(gofmt -l . | grep -v vendor/)"
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
go-version: 1.19
- name: Set up Revive (linter)
run: go get -u github.com/boyter/scc github.com/mgechev/revive
env:
GO111MODULE: "off"
- name: Build
run: go build -mod vendor ./cmd/cheat
run: make build
- name: Test
run: go test ./...
run: make test
build-osx:
runs-on: [macos-latest]
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.19
- name: Set up Revive (linter)
run: go get -u github.com/boyter/scc github.com/mgechev/revive
env:
GO111MODULE: "off"
- name: Build
run: make build
- name: Test
run: make test

View File

@@ -19,12 +19,12 @@ jobs:
language: [go]
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v1

19
.github/workflows/homebrew.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
---
name: homebrew
on:
push:
tags: '*'
jobs:
homebrew:
name: Bump Homebrew formula
runs-on: ubuntu-latest
steps:
- uses: mislav/bump-homebrew-formula-action@v1
with:
# A PR will be sent to github.com/Homebrew/homebrew-core to update
# this formula:
formula-name: cheat
env:
COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }}

3
.gitignore vendored
View File

@@ -1,5 +1,2 @@
dist
tags
.tmp
*.test
.claude

View File

@@ -1,105 +0,0 @@
{
"version": "1.0",
"test_command": "go test ./...",
"last_updated": "2026-02-15T00:00:00Z",
"modules": {
"internal/sheet/parse.go": {
"status": "completed",
"covering_tests": ["internal/sheet/parse_test.go", "internal/sheet/parse_extended_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 8,
"mutations_killed": 8,
"mutation_score": 100.0,
"notes": "Originally 7/8 (87.5%). Added TestHasMalformedYAML to kill YAML unmarshal error survivor."
},
"internal/config/validate.go": {
"status": "completed",
"covering_tests": ["internal/config/validate_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 8,
"mutations_killed": 8,
"mutation_score": 100.0,
"notes": "Originally 7/8 (87.5%). Added TestInvalidateInvalidCheatpath to kill cheatpath.Validate() delegation survivor."
},
"internal/sheets/filter.go": {
"status": "completed",
"covering_tests": ["internal/sheets/filter_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 7,
"mutations_killed": 5,
"mutation_score": 71.4,
"notes": "Survivors relate to UTF-8 condition ordering and OR→AND on dead code path. Not actionable — logically equivalent mutations."
},
"internal/config/paths.go": {
"status": "completed",
"covering_tests": ["internal/config/paths_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 8,
"mutations_killed": 8,
"mutation_score": 100.0,
"notes": "Perfect score. Excellent existing coverage."
},
"internal/sheet/colorize.go": {
"status": "completed",
"covering_tests": ["internal/sheet/colorize_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 5,
"mutations_killed": 5,
"mutation_score": 100.0,
"notes": "Originally 2/5 (40%). Added TestColorizeDefaultSyntax and TestColorizeExplicitSyntax. All 5 mutations now killed."
},
"internal/sheets/consolidate.go": {
"status": "completed",
"covering_tests": ["internal/sheets/consolidate_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 2,
"mutations_killed": 2,
"mutation_score": 100.0,
"notes": "Override semantics well-tested."
},
"internal/display/indent.go": {
"status": "completed",
"covering_tests": ["internal/display/indent_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 3,
"mutations_killed": 3,
"mutation_score": 100.0,
"notes": "Originally 2/3 (66.7%). Added TestIndentTrimsWhitespace to kill TrimSpace survivor."
},
"internal/display/faint.go": {
"status": "completed",
"covering_tests": ["internal/display/faint_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 3,
"mutations_killed": 3,
"mutation_score": 100.0,
"notes": "Perfect score."
},
"internal/sheets/tags.go": {
"status": "completed",
"covering_tests": ["internal/sheets/tags_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 2,
"mutations_killed": 2,
"mutation_score": 100.0,
"notes": "UTF-8 validation and sort order both tested."
},
"internal/sheet/validate.go": {
"status": "completed",
"covering_tests": ["internal/sheet/validate_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 10,
"mutations_killed": 10,
"mutation_score": 100.0,
"notes": "Perfect score. All security checks well-tested."
}
},
"global_statistics": {
"total_modules": 10,
"completed_modules": 10,
"total_mutations": 56,
"total_killed": 54,
"total_survived": 2,
"overall_score": 96.4
}
}

126
CLAUDE.md
View File

@@ -1,126 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Building
```bash
# Build for your architecture
make build
# Build release binaries for all platforms
make build-release
# Install cheat to your PATH
make install
```
### Testing and Quality Checks
```bash
# Run all tests
make test
go test ./...
# Run a single test
go test -run TestFunctionName ./internal/package_name
# Generate test coverage report
make coverage
# Run linter (revive)
make lint
# Run go vet
make vet
# Format code
make fmt
# Run all checks (vendor, fmt, lint, vet, test)
make check
```
### Development Setup
```bash
# Install development dependencies (revive linter, scc)
make setup
# Update and verify vendored dependencies
make vendor-update
```
## Architecture Overview
The `cheat` command-line tool is organized into several key packages:
### Command Layer (`cmd/cheat/`)
- `main.go`: Entry point, cobra command definition, flag registration, command routing
- `cmd_*.go`: Individual command implementations (view, edit, list, search, etc.)
- Commands are routed via a `switch` block in the cobra `RunE` handler
### Completions (`internal/completions/`)
- Dynamic shell completion functions for cheatsheet names, tags, and cheatpath names
- `generate.go`: Generates completion scripts for bash, zsh, fish, and powershell
### Core Internal Packages
1. **`internal/config`**: Configuration management
- Loads YAML config from platform-specific paths
- Manages editor, pager, colorization settings
- Validates and expands cheatpath configurations
2. **`internal/cheatpath`**: Cheatsheet path management
- Represents collections of cheatsheets on filesystem
- Handles read-only vs writable paths
- Supports filtering and validation
3. **`internal/sheet`**: Individual cheatsheet handling
- Parses YAML frontmatter for tags and syntax
- Implements syntax highlighting via Chroma
- Provides search functionality within sheets
4. **`internal/sheets`**: Collection operations
- Loads sheets from multiple cheatpaths
- Consolidates duplicates (local overrides global)
- Filters by tags and sorts results
5. **`internal/display`**: Output formatting
- Writes to stdout or pager
- Handles text formatting and indentation
6. **`internal/installer`**: First-run installer
- Prompts user for initial configuration choices
- Generates default `conf.yml` and downloads community cheatsheets
7. **`internal/repo`**: Git repository management
- Clones community cheatsheet repositories
- Updates existing repositories
### Key Design Patterns
- **Filesystem-based storage**: Cheatsheets are plain text files
- **Override mechanism**: Local sheets override community sheets with same name
- **Tag system**: Sheets can be categorized with tags in frontmatter
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
- **Directory-scoped discovery**: Walks up from cwd to find the nearest `.cheat` directory (like `.git` discovery)
### Sheet Format
Cheatsheets are plain text files optionally prefixed with YAML frontmatter:
```
---
syntax: bash
tags: [ networking, ssh ]
---
# SSH tunneling example
ssh -L 8080:localhost:80 user@remote
```
### Working with the Codebase
- Always check for `.git` directories and skip them during filesystem walks
- Use `go-git` for repository operations, not exec'ing git commands
- Platform-specific paths are handled in `internal/config/paths.go`
- Color output uses ANSI codes via the Chroma library
- Test files use the `mocks` package for test data

View File

@@ -1,17 +1,48 @@
# Contributing
CONTRIBUTING
============
Do you want to contribute to `cheat`? There are a few ways to help:
Thank you for your interest in `cheat`.
#### Submit a cheatsheet ####
Do you have a witty bash one-liner to share? [Open a pull-request][pr] against
the [cheatsheets][] repository. (The `cheat` executable source code lives in
[cheat/cheat][cheat]. Cheatsheet content lives in
[cheat/cheatsheets][cheatsheets].)
Pull requests are no longer being accepted, and have been disabled on this
repository. The maintainer is not currently reviewing or merging external code
contributions.
#### Report a bug ####
Did you find a bug? Report it in the [issue tracker][issues]. (But before you
do, please look through the open issues to make sure that it hasn't already
been reported.)
Bug reports are still welcome. If you've found a bug, please open an issue in
the [issue tracker][issues]. Before doing so, please search through the
existing open issues to make sure it hasn't already been reported.
#### Add a feature ####
Do you have a feature that you'd like to contribute? Propose it in the [issue
tracker][issues] to discuss with the maintainer whether it would be considered
for merging.
Feature requests may be filed, but are unlikely to be implemented. The project
is now mature and the maintainer considers its feature set to be essentially
complete.
`cheat` is mostly mature and feature-complete, but may still have some room for
new features. See [HACKING.md][hacking] for a quick-start guide to `cheat`
development.
[issues]: https://github.com/cheat/cheat/issues
#### Add documentation ####
Did you encounter features, bugs, edge-cases, use-cases, or environment
considerations that were undocumented or under-documented? Add them to the
[wiki][]. (You may also open a pull-request against the `README`, if
appropriate.)
Do you enjoy technical writing or proofreading? Help keep the documentation
error-free and well-organized.
#### Spread the word ####
Are you unable to do the above, but still want to contribute? You can help
`cheat` simply by telling others about it. Share it with friends and coworkers
that might benefit from using it.
#### Pull Requests ####
Please open all pull-requests against the `develop` branch.
[cheat]: https://github.com/cheat/cheat
[cheatsheets]: https://github.com/cheat/cheatsheets
[hacking]: HACKING.md
[issues]: https://github.com/cheat/cheat/issues
[pr]: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork
[wiki]: https://github.com/cheat/cheat/wiki

View File

@@ -1,7 +1,7 @@
# NB: this image isn't used anywhere in the build pipeline. It exists to
# conveniently facilitate ad-hoc experimentation in a sandboxed environment
# during development.
FROM golang:1.26-alpine
FROM golang:1.15-alpine
RUN apk add git less make

View File

@@ -1,241 +1,57 @@
# Hacking Guide
Hacking
=======
The following is a quickstart guide for developing `cheat`.
This document provides a comprehensive guide for developing `cheat`, including setup, architecture overview, and code patterns.
## 1. Install system dependencies
Before you begin, you must install a handful of system dependencies. The
following are required, and must be available on your `PATH`:
## Quick Start
### 1. Install system dependencies
The following are required and must be available on your `PATH`:
- `git`
- `go` (>= 1.19 is recommended)
- `go` (>= 1.17 is recommended)
- `make`
Optional dependencies:
The following dependencies are optional:
- `docker`
- `pandoc` (necessary to generate a `man` page)
### 2. Install utility applications
Run `make setup` to install `scc` and `revive`, which are used by various `make` targets.
## 2. Install utility applications
Run `make setup` to install `scc` and `revive`, which are used by various
`make` targets.
### 3. Development workflow
## 3. Development workflow
After your environment has been configured, your development workflow will
resemble the following:
1. Make changes to the `cheat` source code
2. Run `make test` to run unit-tests
3. Fix compiler errors and failing tests as necessary
4. Run `make build`. A `cheat` executable will be written to the `dist` directory
5. Use the new executable by running `dist/cheat <command>`
6. Run `make install` to install `cheat` to your `PATH`
7. Run `make build-release` to build cross-platform binaries in `dist`
8. Run `make clean` to clean the `dist` directory when desired
1. Make changes to the `cheat` source code.
2. Run `make test` to run unit-tests.
3. Fix compiler errors and failing tests as necessary.
4. Run `make`. A `cheat` executable will be written to the `dist` directory.
5. Use the new executable by running `dist/cheat <command>`.
6. Run `make install` to install `cheat` to your `PATH`.
7. Run `make build-release` to build cross-platform binaries in `dist`.
8. Run `make clean` to clean the `dist` directory when desired.
You may run `make help` to see a list of available `make` commands.
### 4. Testing
### Developing with docker
It may be useful to test your changes within a pristine environment. An
Alpine-based docker container has been provided for that purpose.
#### Unit Tests
Run unit tests with:
```bash
make test
```
#### Integration Tests
Integration tests that require network access are separated using build tags. Run them with:
```bash
make test-integration
```
To run all tests (unit and integration):
```bash
make test-all
```
#### Test Coverage
Generate a coverage report with:
```bash
make coverage # HTML report
make coverage-text # Terminal output
```
## Architecture Overview
### Package Structure
The `cheat` application follows a clean architecture with well-separated concerns:
- **`cmd/cheat/`**: Command layer (cobra-based CLI, flag registration, command routing, shell completions)
- **`internal/config`**: Configuration management (YAML loading, validation, paths)
- **`internal/cheatpath`**: Cheatsheet path management (collections, filtering)
- **`internal/sheet`**: Individual cheatsheet handling (parsing, search, highlighting)
- **`internal/sheets`**: Collection operations (loading, consolidation, filtering)
- **`internal/display`**: Output formatting (pager integration, colorization)
- **`internal/repo`**: Git repository management for community sheets
### Key Design Patterns
- **Filesystem-based storage**: Cheatsheets are plain text files
- **Override mechanism**: Local sheets override community sheets with same name
- **Tag system**: Sheets can be categorized with tags in frontmatter
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
## Core Types and Functions
### Config (`internal/config`)
The main configuration structure:
```go
type Config struct {
Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"`
Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"`
Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"`
Path string
}
```
Key functions:
- `New(confPath, resolve)` - Load config from file
- `Validate()` - Validate configuration values
- `Editor()` - Get editor from environment or defaults (package-level function)
- `Pager()` - Get pager from environment or defaults (package-level function)
### Cheatpath (`internal/cheatpath`)
Represents a directory containing cheatsheets:
```go
type Path struct {
Name string // Friendly name (e.g., "personal")
Path string // Filesystem path
Tags []string // Tags applied to all sheets in this path
ReadOnly bool // Whether sheets can be modified
}
```
### Sheet (`internal/sheet`)
Represents an individual cheatsheet:
```go
type Sheet struct {
Title string // Sheet name (from filename)
CheatPath string // Name of the cheatpath this sheet belongs to
Path string // Full filesystem path
Text string // Content (without frontmatter)
Tags []string // Combined tags (from frontmatter + cheatpath)
Syntax string // Syntax for highlighting
ReadOnly bool // Whether sheet can be edited
}
```
Key methods:
- `New(title, cheatpath, path, tags, readOnly)` - Load from file
- `Search(reg)` - Search content with a compiled regexp
- `Colorize(conf)` - Apply syntax highlighting (modifies sheet in place)
- `Tagged(needle)` - Check if sheet has the given tag
## Common Operations
### Loading and Displaying a Sheet
```go
// Load sheet
s, err := sheet.New("tar", "personal", "/path/to/tar", []string{"personal"}, false)
if err != nil {
log.Fatal(err)
}
// Apply syntax highlighting (modifies sheet in place)
s.Colorize(conf)
// Display with pager
display.Write(s.Text, conf)
```
### Working with Sheet Collections
```go
// Load all sheets from cheatpaths (returns a slice of maps, one per cheatpath)
allSheets, err := sheets.Load(conf.Cheatpaths)
if err != nil {
log.Fatal(err)
}
// Consolidate to handle duplicates (later cheatpaths take precedence)
consolidated := sheets.Consolidate(allSheets)
// Filter by tag (operates on the slice of maps)
filtered := sheets.Filter(allSheets, []string{"networking"})
// Sort alphabetically (returns a sorted slice)
sorted := sheets.Sort(consolidated)
```
### Sheet Format
Cheatsheets are plain text files that may begin with YAML frontmatter:
```yaml
---
syntax: bash
tags: [networking, linux, ssh]
---
# Connect to remote server
ssh user@hostname
# Copy files over SSH
scp local_file user@hostname:/remote/path
```
## Testing
Run tests with:
```bash
make test # Run all tests
make coverage # Generate coverage report
go test ./... # Go test directly
```
Test files follow Go conventions:
- `*_test.go` files in same package
- Table-driven tests for multiple scenarios
- Mock data in `mocks` package
## Error Handling
The codebase follows consistent error handling patterns:
- Functions return explicit errors
- Errors are wrapped with context using `fmt.Errorf`
- User-facing errors are written to stderr
Example:
```go
s, err := sheet.New(title, cheatpath, path, tags, false)
if err != nil {
return fmt.Errorf("failed to load sheet: %w", err)
}
```
## Developing with Docker
It may be useful to test your changes within a pristine environment. An Alpine-based docker container has been provided for that purpose.
Build the docker container:
```bash
If you would like to build the docker container, run:
```sh
make docker-setup
```
Shell into the container:
```bash
To shell into the container, run:
```sh
make docker-sh
```
The `cheat` source code will be mounted at `/app` within the container.
To destroy the container:
```bash
If you would like to destroy this container, you may run:
```sh
make distclean
```
[go]: https://go.dev/

View File

@@ -1,46 +1,57 @@
# Installing
Installing
==========
`cheat` has no runtime dependencies. As such, installing it is generally
straightforward. There are a few methods available:
## Install manually
### Unix-like
### Install manually
#### Unix-like
On Unix-like systems, you may simply paste the following snippet into your terminal:
```sh
cd /tmp \
&& wget https://github.com/cheat/cheat/releases/download/5.0.0/cheat-linux-amd64.gz \
&& wget https://github.com/cheat/cheat/releases/download/4.4.0/cheat-linux-amd64.gz \
&& gunzip cheat-linux-amd64.gz \
&& chmod +x cheat-linux-amd64 \
&& sudo mv cheat-linux-amd64 /usr/local/bin/cheat
```
You may need to need to change the version number (`5.0.0`) and the archive
You may need to need to change the version number (`4.4.0`) and the archive
(`cheat-linux-amd64.gz`) depending on your platform.
See the [releases page][releases] for a list of supported platforms.
### Windows
On Windows, download the appropriate binary from the [releases page][releases],
unzip the archive, and place the `cheat.exe` executable on your `PATH`.
#### Windows
To install "cheat" on Windows using PowerShell, follow these steps:
## Install via `go install`
If you have `go` version `>=1.26` available on your `PATH`, you can install
- Open PowerShell as an administrator. You can do this by right-clicking the Windows PowerShell or PowerShell application in the Start menu and selecting 'Run as administrator'.
- Copy and paste the following command into the elevated PowerShell window:
```sh
Invoke-WebRequest -Uri 'https://github.com/cheat/cheat/releases/download/4.4.0/cheat-windows-amd64.exe.zip' -OutFile cheat-windows-amd64.exe.zip ; Expand-Archive -Path .\cheat-windows-amd64.exe.zip -DestinationPath 'C:\Program Files\Cheat' -Force ; Rename-Item -Path 'C:\Program Files\Cheat\cheat-windows-amd64.exe' -NewName 'cheat.exe' ; [System.Environment]::SetEnvironmentVariable('Path', [System.Environment]::GetEnvironmentVariable('Path', [System.EnvironmentVariableTarget]::Machine) + ';C:\Program Files\Cheat', [System.EnvironmentVariableTarget]::Machine)
```
- Open the shell again for a new session and use "cheat".
### Install via `go install`
If you have `go` version `>=1.17` available on your `PATH`, you can install
`cheat` via `go install`:
```sh
go install github.com/cheat/cheat/cmd/cheat@latest
```
## Install via package manager
### Install via package manager
Several community-maintained packages are also available:
Package manager | Package(s)
---------------- | -----------
aur | [cheat][pkg-aur-cheat], [cheat-bin][pkg-aur-cheat-bin]
brew | [cheat][pkg-brew]
docker | [docker-cheat][pkg-docker]
nix | [nixos.cheat][pkg-nix]
snap | [cheat][pkg-snap]
| Package manager | Package(s) |
|-----------------|--------------------------------------------------------|
| aur | [cheat][pkg-aur-cheat], [cheat-bin][pkg-aur-cheat-bin] |
| brew | [cheat][pkg-brew] |
| docker | [docker-cheat][pkg-docker] |
| nix | [nixos.cheat][pkg-nix] |
| snap | [cheat][pkg-snap] |
<!--[pacman][] |-->
## Configuring
Three things must be done before you can use `cheat`:
@@ -53,7 +64,7 @@ automatically. After the installer is complete, it is strongly advised that you
view the configuration file that was generated, as you may want to change some
of its default values (to enable colorization, change the paginator, etc).
### conf.yml
### conf.yml ###
`cheat` is configured by a YAML file that will be auto-generated on first run.
By default, the config file is assumed to exist on an XDG-compliant
@@ -69,8 +80,8 @@ export CHEAT_CONFIG_PATH="~/.dotfiles/cheat/conf.yml"
[community]: https://github.com/cheat/cheatsheets/
[pkg-aur-cheat-bin]: https://aur.archlinux.org/packages/cheat-bin
[pkg-aur-cheat]: https://aur.archlinux.org/packages/cheat
[pkg-brew]: https://formulae.brew.sh/formula/cheat
[pkg-brew]: https://formulae.brew.sh/formula/cheat
[pkg-docker]: https://github.com/bannmann/docker-cheat
[pkg-nix]: https://search.nixos.org/packages?channel=unstable&show=cheat&from=0&size=50&sort=relevance&type=packages&query=cheat
[pkg-nix]: https://search.nixos.org/packages?channel=unstable&show=cheat&from=0&size=50&sort=relevance&type=packages&query=cheat
[pkg-snap]: https://snapcraft.io/cheat
[releases]: https://github.com/cheat/cheat/releases

118
Makefile
View File

@@ -3,9 +3,6 @@ makefile := $(realpath $(lastword $(MAKEFILE_LIST)))
cmd_dir := ./cmd/cheat
dist_dir := ./dist
# parallel jobs for build-release (can be overridden)
JOBS ?= 8
# executables
CAT := cat
COLUMN := column
@@ -27,7 +24,6 @@ ZIP := zip -m
docker_image := cheat-devel:latest
# build flags
export CGO_ENABLED := 0
BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath
GOBIN :=
TMPDIR := /tmp
@@ -35,7 +31,6 @@ TMPDIR := /tmp
# release binaries
releases := \
$(dist_dir)/cheat-darwin-amd64 \
$(dist_dir)/cheat-darwin-arm64 \
$(dist_dir)/cheat-linux-386 \
$(dist_dir)/cheat-linux-amd64 \
$(dist_dir)/cheat-linux-arm5 \
@@ -44,78 +39,76 @@ releases := \
$(dist_dir)/cheat-linux-arm7 \
$(dist_dir)/cheat-netbsd-amd64 \
$(dist_dir)/cheat-openbsd-amd64 \
$(dist_dir)/cheat-plan9-amd64 \
$(dist_dir)/cheat-solaris-amd64 \
$(dist_dir)/cheat-windows-amd64.exe
## build: build an executable for your architecture
.PHONY: build
build: | clean $(dist_dir) fmt lint vet vendor man
build: | clean $(dist_dir) generate fmt lint vet vendor man
$(GO) build $(BUILD_FLAGS) -o $(dist_dir)/cheat $(cmd_dir)
## build-release: build release executables
# Runs prepare once, then builds all binaries in parallel
# Override jobs with: make build-release JOBS=16
.PHONY: build-release
build-release: prepare
$(MAKE) -j$(JOBS) $(releases)
build-release: $(releases)
# cheat-darwin-amd64
$(dist_dir)/cheat-darwin-amd64:
$(dist_dir)/cheat-darwin-amd64: prepare
GOARCH=amd64 GOOS=darwin \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-darwin-arm64
$(dist_dir)/cheat-darwin-arm64:
GOARCH=arm64 GOOS=darwin \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-386
$(dist_dir)/cheat-linux-386:
$(dist_dir)/cheat-linux-386: prepare
GOARCH=386 GOOS=linux \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-amd64
$(dist_dir)/cheat-linux-amd64:
$(dist_dir)/cheat-linux-amd64: prepare
GOARCH=amd64 GOOS=linux \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-arm5
$(dist_dir)/cheat-linux-arm5:
$(dist_dir)/cheat-linux-arm5: prepare
GOARCH=arm GOOS=linux GOARM=5 \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-arm6
$(dist_dir)/cheat-linux-arm6:
$(dist_dir)/cheat-linux-arm6: prepare
GOARCH=arm GOOS=linux GOARM=6 \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-arm7
$(dist_dir)/cheat-linux-arm7:
$(dist_dir)/cheat-linux-arm7: prepare
GOARCH=arm GOOS=linux GOARM=7 \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-arm64
$(dist_dir)/cheat-linux-arm64:
$(dist_dir)/cheat-linux-arm64: prepare
GOARCH=arm64 GOOS=linux \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-netbsd-amd64
$(dist_dir)/cheat-netbsd-amd64:
$(dist_dir)/cheat-netbsd-amd64: prepare
GOARCH=amd64 GOOS=netbsd \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-openbsd-amd64
$(dist_dir)/cheat-openbsd-amd64:
$(dist_dir)/cheat-openbsd-amd64: prepare
GOARCH=amd64 GOOS=openbsd \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-plan9-amd64
$(dist_dir)/cheat-plan9-amd64: prepare
GOARCH=amd64 GOOS=plan9 \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-solaris-amd64
$(dist_dir)/cheat-solaris-amd64:
$(dist_dir)/cheat-solaris-amd64: prepare
GOARCH=amd64 GOOS=solaris \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-windows-amd64
$(dist_dir)/cheat-windows-amd64.exe:
$(dist_dir)/cheat-windows-amd64.exe: prepare
GOARCH=amd64 GOOS=windows \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(ZIP) $@.zip $@ -j
@@ -123,9 +116,9 @@ $(dist_dir)/cheat-windows-amd64.exe:
$(dist_dir):
$(MKDIR) $(dist_dir)
# .tmp
.tmp:
$(MKDIR) .tmp
.PHONY: generate
generate:
$(GO) generate $(cmd_dir)
## install: build and install cheat on your PATH
.PHONY: install
@@ -135,8 +128,7 @@ install: build
## clean: remove compiled executables
.PHONY: clean
clean:
$(RM) -f $(dist_dir)/*
$(RM) -rf .tmp
$(RM) -f $(dist_dir)/* $(cmd_dir)/str_config.go $(cmd_dir)/str_usage.go
## distclean: remove the tags file
.PHONY: distclean
@@ -147,8 +139,7 @@ distclean:
## setup: install revive (linter) and scc (sloc tool)
.PHONY: setup
setup:
$(GO) install github.com/boyter/scc@latest
$(GO) install github.com/mgechev/revive@latest
GO111MODULE=off $(GO) get -u github.com/boyter/scc github.com/mgechev/revive
## sloc: count "semantic lines of code"
.PHONY: sloc
@@ -172,7 +163,6 @@ vendor:
$(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
## vendor-update: update vendored dependencies
.PHONY: vendor-update
vendor-update:
$(GO) get -t -u ./... && $(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
@@ -196,70 +186,18 @@ vet:
test:
$(GO) test ./...
## test-integration: run integration tests (requires network)
.PHONY: test-integration
test-integration:
$(GO) test -tags=integration -count=1 ./...
## test-all: run all tests (unit and integration)
.PHONY: test-all
test-all: test test-integration
## test-fuzz: run quick fuzz tests for security-critical functions
.PHONY: test-fuzz
test-fuzz:
@./test/fuzz.sh 15s
## test-fuzz-long: run extended fuzz tests (10 minutes each)
.PHONY: test-fuzz-long
test-fuzz-long:
@./test/fuzz.sh 10m
## coverage: generate a test coverage report
.PHONY: coverage
coverage: .tmp
$(GO) test ./... -coverprofile=.tmp/cheat-coverage.out && \
$(GO) tool cover -html=.tmp/cheat-coverage.out -o .tmp/cheat-coverage.html && \
echo "Coverage report generated: .tmp/cheat-coverage.html" && \
(sensible-browser .tmp/cheat-coverage.html 2>/dev/null || \
xdg-open .tmp/cheat-coverage.html 2>/dev/null || \
open .tmp/cheat-coverage.html 2>/dev/null || \
echo "Please open .tmp/cheat-coverage.html in your browser")
## coverage-text: show test coverage by function in terminal
.PHONY: coverage-text
coverage-text: .tmp
$(GO) test ./... -coverprofile=.tmp/cheat-coverage.out && \
$(GO) tool cover -func=.tmp/cheat-coverage.out | $(SORT) -k3 -n
## benchmark: run performance benchmarks
.PHONY: benchmark
benchmark: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./test/integration | tee .tmp/benchmark-latest.txt && \
$(RM) -f integration.test
## benchmark-cpu: run benchmarks with CPU profiling
.PHONY: benchmark-cpu
benchmark-cpu: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./test/integration && \
$(RM) -f integration.test && \
echo "CPU profile saved to .tmp/cpu.prof" && \
echo "View with: go tool pprof -http=:8080 .tmp/cpu.prof"
## benchmark-mem: run benchmarks with memory profiling
.PHONY: benchmark-mem
benchmark-mem: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./test/integration && \
$(RM) -f integration.test && \
echo "Memory profile saved to .tmp/mem.prof" && \
echo "View with: go tool pprof -http=:8080 .tmp/mem.prof"
coverage:
$(GO) test ./... -coverprofile=$(TMPDIR)/cheat-coverage.out && \
$(GO) tool cover -html=$(TMPDIR)/cheat-coverage.out
## check: format, lint, vet, vendor, and run unit-tests
.PHONY: check
check: | vendor fmt lint vet test
.PHONY: prepare
prepare: | clean $(dist_dir) vendor fmt lint vet test
prepare: | clean $(dist_dir) generate vendor fmt lint vet test
## docker-setup: create a docker image for use during development
.PHONY: docker-setup

View File

@@ -1,6 +1,8 @@
![Workflow status](https://github.com/cheat/cheat/actions/workflows/build.yml/badge.svg)
# cheat
cheat
=====
`cheat` allows you to create and view interactive cheatsheets on the
command-line. It was designed to help remind \*nix system administrators of
@@ -11,7 +13,9 @@ remember.
Use `cheat` with [cheatsheets][].
## Example
Example
-------
The next time you're forced to disarm a nuclear weapon without consulting
Google, you may run:
@@ -38,10 +42,8 @@ tar -xjvf '/path/to/foo.tgz'
tar -cjvf '/path/to/foo.tgz' '/path/to/foo/'
```
## Installing
For installation and configuration instructions, see [INSTALLING.md][].
## Usage
Usage
-----
To view a cheatsheet:
```sh
@@ -68,12 +70,6 @@ To list all available cheatsheets:
cheat -l
```
To briefly list all cheatsheets (names and tags only):
```sh
cheat -b
```
To list all cheatsheets that are tagged with "networking":
```sh
@@ -105,7 +101,14 @@ Flags may be combined in intuitive ways. Example: to search sheets on the
cheat -p personal -t networking --regex -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
```
## Cheatsheets
Installing
----------
For installation and configuration instructions, see [INSTALLING.md][].
Cheatsheets
-----------
Cheatsheets are plain-text files with no file extension, and are named
according to the command used to view them:
@@ -114,7 +117,7 @@ cheat tar # file is named "tar"
cheat foo/bar # file is named "bar", in a "foo" subdirectory
```
Cheatsheet text may optionally be preceded by a YAML frontmatter header that
Cheatsheet text may optionally be preceeded by a YAML frontmatter header that
assigns tags and specifies syntax:
```
@@ -126,15 +129,12 @@ tags: [ array, map ]
const squares = [1, 2, 3, 4].map(x => x * x);
```
Syntax highlighting is provided by [Chroma][], and the `syntax` value may be
set to any lexer name that Chroma supports. See Chroma's [supported
languages][] for a complete list.
The `cheat` executable includes no cheatsheets, but [community-sourced
cheatsheets are available][cheatsheets]. You will be asked if you would like to
install the community-sourced cheatsheets the first time you run `cheat`.
## Cheatpaths
Cheatpaths
----------
Cheatsheets are stored on "cheatpaths", which are directories that contain
cheatsheets. Cheatpaths are specified in the `conf.yml` file.
@@ -166,47 +166,28 @@ If a user attempts to edit a cheatsheet on a read-only cheatpath, `cheat` will
transparently copy that sheet to a writeable directory before opening it for
editing.
### Directory-scoped Cheatpaths
### Directory-scoped Cheatpaths ###
At times, it can be useful to closely associate cheatsheets with a directory on
your filesystem. `cheat` facilitates this by searching for a `.cheat` directory
in the current working directory and its ancestors (similar to how `git` locates
`.git` directories). The nearest `.cheat` directory found will (temporarily) be
added to the cheatpaths. This means you can place a `.cheat` directory at your
project root and it will be available from any subdirectory within that project.
your filesystem. `cheat` facilitates this by searching for a `.cheat` folder in
the current working directory. If found, the `.cheat` directory will
(temporarily) be added to the cheatpaths.
## Autocompletion
`cheat` can generate shell completion scripts for `bash`, `zsh`, `fish`, and
`powershell` via the `--completion` flag:
Autocompletion
--------------
Shell autocompletion is currently available for `bash`, `fish`, and `zsh`. Copy
the relevant [completion script][completions] into the appropriate directory on
your filesystem to enable autocompletion. (This directory will vary depending
on operating system and shell specifics.)
```sh
cheat --completion bash
cheat --completion zsh
cheat --completion fish
cheat --completion powershell
```
Additionally, `cheat` supports enhanced autocompletion via integration with
[fzf][]. To enable `fzf` integration:
Pipe the output to the appropriate location for your shell. For example:
```sh
# bash (user-local)
mkdir -p ~/.local/share/bash-completion/completions
cheat --completion bash > ~/.local/share/bash-completion/completions/cheat
# bash (system-wide)
cheat --completion bash > /etc/bash_completion.d/cheat
# zsh (ensure the directory is on your fpath)
cheat --completion zsh > "${fpath[1]}/_cheat"
# fish
cheat --completion fish > ~/.config/fish/completions/cheat.fish
```
Completions are dynamically generated and include cheatsheet names, tags, and
cheatpath names.
1. Ensure that `fzf` is available on your `$PATH`
2. Set an envvar: `export CHEAT_USE_FZF=true`
[INSTALLING.md]: INSTALLING.md
[Releases]: https://github.com/cheat/cheat/releases
[cheatsheets]: https://github.com/cheat/cheatsheets
[Chroma]: https://github.com/alecthomas/chroma
[supported languages]: https://github.com/alecthomas/chroma#supported-languages
[completions]: https://github.com/cheat/cheat/tree/master/scripts
[fzf]: https://github.com/junegunn/fzf
[go]: https://golang.org

View File

@@ -1,169 +0,0 @@
# ADR-001: Path Traversal Protection for Cheatsheet Names
Date: 2025-01-21
## Status
Accepted
## Context
The `cheat` tool allows users to create, edit, and remove cheatsheets using commands like:
- `cheat --edit <name>`
- `cheat --rm <name>`
Without validation, a user could potentially provide malicious names like:
- `../../../etc/passwd` (directory traversal)
- `/etc/passwd` (absolute path)
- `~/.ssh/authorized_keys` (home directory expansion)
While `cheat` is a local tool run by the user themselves (not a network service), path traversal could still lead to:
1. Accidental file overwrites outside cheatsheet directories
2. Confusion about where files are being created
3. Potential security issues in shared environments
## Decision
We implemented input validation for cheatsheet names to prevent directory traversal attacks. The validation rejects names that:
1. Contain `..` (parent directory references)
2. Are absolute paths (start with `/` on Unix)
3. Start with `~` (home directory expansion)
4. Are empty
5. Start with `.` (hidden files - these are not displayed by cheat)
The validation is performed at the application layer before any file operations occur.
## Implementation Details
### Validation Function
The validation is implemented in `internal/sheet/validate.go`:
```go
func Validate(name string) error {
// Reject empty names
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
}
// Reject names containing directory traversal
if strings.Contains(name, "..") {
return fmt.Errorf("cheatsheet name cannot contain '..'")
}
// Reject absolute paths
if filepath.IsAbs(name) {
return fmt.Errorf("cheatsheet name cannot be an absolute path")
}
// Reject names that start with ~ (home directory expansion)
if strings.HasPrefix(name, "~") {
return fmt.Errorf("cheatsheet name cannot start with '~'")
}
// Reject hidden files (files that start with a dot)
filename := filepath.Base(name)
if strings.HasPrefix(filename, ".") {
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
}
return nil
}
```
### Integration Points
The validation is called in:
- `cmd/cheat/cmd_edit.go` - before creating or editing a cheatsheet
- `cmd/cheat/cmd_remove.go` - before removing a cheatsheet
### Allowed Patterns
The following patterns are explicitly allowed:
- Simple names: `docker`, `git`
- Nested paths: `docker/compose`, `lang/go/slice`
- Current directory references: `./mysheet`
## Consequences
### Positive
1. **Safety**: Prevents accidental or intentional file operations outside cheatsheet directories
2. **Simplicity**: Validation happens early, before any file operations
3. **User-friendly**: Clear error messages explain why a name was rejected
4. **Performance**: Minimal overhead - simple string checks
5. **Compatibility**: Doesn't break existing valid cheatsheet names
### Negative
1. **Limitation**: Users cannot use `..` in cheatsheet names even if legitimate
2. **No symlink support**: Cannot create cheatsheets through symlinks outside the cheatpath
### Neutral
1. Uses Go's `filepath.IsAbs()` which handles platform differences (Windows vs Unix)
2. No attempt to resolve or canonicalize paths - validation is purely syntactic
## Security Considerations
### Threat Model
`cheat` is a local command-line tool, not a network service. The primary threats are:
- User error (accidentally overwriting important files)
- Malicious scripts that invoke `cheat` with crafted arguments
- Shared system scenarios where cheatsheets might be shared
### What This Protects Against
- Directory traversal using `../`
- Absolute path access to system files
- Shell expansion of `~` to home directory
- Empty names that might cause unexpected behavior
- Hidden files that wouldn't be displayed anyway
### What This Does NOT Protect Against
- Users with filesystem permissions can still directly edit any file
- Symbolic links within the cheatpath pointing outside
- Race conditions (TOCTOU) - though minimal risk for a local tool
- Malicious content within cheatsheets themselves
## Testing
Comprehensive tests ensure the validation works correctly:
1. **Unit tests** (`internal/sheet/validate_test.go`) verify the validation logic
2. **Integration tests** verify the actual binary blocks malicious inputs
3. **No system files are accessed** during testing - all tests use isolated directories
Example test cases:
```bash
# These are blocked:
cheat --edit "../../../etc/passwd"
cheat --edit "/etc/passwd"
cheat --edit "~/.ssh/config"
cheat --rm ".."
# These are allowed:
cheat --edit "docker"
cheat --edit "docker/compose"
cheat --edit "./local"
```
## Alternative Approaches Considered
1. **Path resolution and verification**: Resolve the final path and check if it's within the cheatpath
- Rejected: More complex, potential race conditions, platform-specific edge cases
2. **Chroot/sandbox**: Run file operations in a restricted environment
- Rejected: Overkill for a local tool, platform compatibility issues
3. **Filename allowlist**: Only allow alphanumeric characters and specific symbols
- Rejected: Too restrictive, would break existing cheatsheets with valid special characters
## References
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- Go filepath package documentation: https://pkg.go.dev/path/filepath

View File

@@ -1,100 +0,0 @@
# ADR-002: No Defensive Checks for Environment Variable Parsing
Date: 2025-01-21
## Status
Accepted
## Context
In the `EnvVars()` function in `internal/config/env.go`, the code parses environment variables assuming they all contain an equals sign:
```go
for _, e := range os.Environ() {
pair := strings.SplitN(e, "=", 2)
if runtime.GOOS == "windows" {
pair[0] = strings.ToUpper(pair[0])
}
envvars[pair[0]] = pair[1] // Could panic if pair has < 2 elements
}
```
If `os.Environ()` returned a string without an equals sign, `strings.SplitN` would return a slice with only one element, causing a panic when accessing `pair[1]`.
## Decision
We will **not** add defensive checks for this condition. The current code that assumes all environment strings contain "=" will remain unchanged.
## Rationale
### Go Runtime Guarantees
Go's official documentation guarantees that `os.Environ()` returns environment variables in the form "key=value". This is a documented contract of the Go runtime that has been stable since Go 1.0.
### Empirical Evidence
Testing across platforms confirms:
- All environment variables returned by `os.Environ()` contain at least one "="
- Empty environment variables appear as "KEY=" (with an empty value)
- Even Windows special variables like "=C:=C:\path" maintain the format
### Cost-Benefit Analysis
Adding defensive code would:
- **Cost**: Add complexity and cognitive overhead
- **Cost**: Suggest uncertainty about Go's documented behavior
- **Cost**: Create dead code that can never execute under normal conditions
- **Benefit**: Protect against a theoretical scenario that violates Go's guarantees
The only scenarios where this could panic are:
1. A bug in Go's runtime (extremely unlikely, would affect all Go programs)
2. Corrupted OS-level environment (would cause broader system issues)
3. Breaking change in future Go version (would break many programs, unlikely)
## Consequences
### Positive
- Simpler, more readable code
- Trust in platform guarantees reduces unnecessary defensive programming
- No performance overhead from unnecessary checks
### Negative
- Theoretical panic if Go's guarantees are violated
### Neutral
- Follows Go community standards of trusting standard library contracts
## Alternatives Considered
### 1. Add Defensive Check
```go
if len(pair) < 2 {
continue // or pair[1] = ""
}
```
**Rejected**: Adds complexity for a condition that should never occur.
### 2. Add Panic with Clear Message
```go
if len(pair) < 2 {
panic("os.Environ() contract violation: " + e)
}
```
**Rejected**: Would crash the program for the same theoretical issue.
### 3. Add Comment Documenting Assumption
```go
// os.Environ() guarantees "key=value" format, so pair[1] is safe
envvars[pair[0]] = pair[1]
```
**Rejected**: While documentation is good, this particular guarantee is fundamental to Go.
## Notes
If Go ever changes this behavior (extremely unlikely as it would break compatibility), it would be caught immediately in testing as the program would panic on startup. This would be a clear signal to revisit this decision.
## References
- Go os.Environ() documentation: https://pkg.go.dev/os#Environ
- Go os.Environ() source code and tests

View File

@@ -1,104 +0,0 @@
# ADR-003: No Parallelization for Search Operations
Date: 2025-01-22
## Status
Accepted
## Context
We investigated optimizing cheat's search performance through parallelization. Initial assumptions suggested that I/O operations (reading multiple cheatsheet files) would be the primary bottleneck, making parallel processing beneficial.
Performance benchmarks were implemented to measure search operations, and a parallel search implementation using goroutines was created and tested.
## Decision
We will **not** implement parallel search. The sequential implementation will remain unchanged.
## Rationale
### Performance Profile Analysis
CPU profiling revealed that search performance is dominated by:
- **Process creation overhead** (~30% in `os/exec.(*Cmd).Run`)
- **System calls** (~30% in `syscall.Syscall6`)
- **Process management** (fork, exec, pipe setup)
The actual search logic (regex matching, file reading) was negligible in the profile, indicating our optimization efforts were targeting the wrong bottleneck.
### Benchmark Results
Parallel implementation showed minimal improvements:
- Simple search: 17ms → 15.3ms (10% improvement)
- Regex search: 15ms → 14.9ms (minimal improvement)
- Colorized search: 19.5ms → 16.8ms (14% improvement)
- Complex regex: 20ms → 15.3ms (24% improvement)
The best case saved only ~5ms in absolute terms.
### Cost-Benefit Analysis
**Costs of parallelization:**
- Added complexity with goroutines, channels, and synchronization
- Increased maintenance burden
- More difficult debugging and testing
- Potential race conditions
**Benefits:**
- 5-15% performance improvement (5ms in real terms)
- Imperceptible to users in interactive use
### User Experience Perspective
For a command-line tool:
- Current 15-20ms response time is excellent
- Users cannot perceive 5ms differences
- Sub-50ms is considered "instant" in HCI research
## Consequences
### Positive
- Simpler, more maintainable codebase
- Easier to debug and reason about
- No synchronization bugs or race conditions
- Focus remains on code clarity
### Negative
- Missed opportunity for ~5ms performance gain
- Search remains single-threaded
### Neutral
- Performance remains excellent for intended use case
- Follows Go philosophy of preferring simplicity
## Alternatives Considered
### 1. Keep Parallel Implementation
**Rejected**: Complexity outweighs negligible performance gains.
### 2. Optimize Process Startup
**Rejected**: Process creation overhead is inherent to CLI tools and cannot be avoided without fundamental architecture changes.
### 3. Future Optimizations
If performance becomes critical, consider:
- **Long-running daemon**: Eliminate process startup overhead entirely
- **Shell function**: Reduce fork/exec overhead
- **Compiled-in cheatsheets**: Eliminate file I/O
However, these would fundamentally change the tool's architecture and usage model.
## Notes
This decision reinforces important principles:
1. Always profile before optimizing
2. Consider the full execution context
3. Measure what matters to users
4. Complexity has a real cost
The parallelization attempt was valuable as a learning exercise and definitively answered whether this optimization path was worthwhile.
## References
- Benchmark implementation: test/integration/search_bench_test.go
- Reverted parallel implementation: see git history (commit 82eb918)

View File

@@ -1,80 +0,0 @@
# ADR-004: Recursive `.cheat` Directory Search
Date: 2026-02-15
## Status
Accepted
## Context
Previously, `cheat` only checked the current working directory for a `.cheat`
subdirectory to use as a directory-scoped cheatpath. If a user was in
`~/projects/myapp/src/handlers/` but the `.cheat` directory lived at
`~/projects/myapp/.cheat`, it would not be found. Users requested (#602) that
`cheat` walk up the directory hierarchy to find the nearest `.cheat`
directory, mirroring the discovery pattern used by `git` for `.git`
directories.
## Decision
Walk upward from the current working directory to the filesystem root, and
stop at the first `.cheat` directory found. Only directories are matched (a
file named `.cheat` is ignored).
### Stop at first `.cheat` found
Rather than collecting multiple `.cheat` directories from ancestor directories:
- Matches `.git` discovery semantics, which users already understand
- Fits the existing single-cheatpath-named-`"cwd"` code without structural
changes
- Avoids precedence and naming complexity when multiple `.cheat` directories
exist in the ancestor chain
- `cheat` already supports multiple cheatpaths via `conf.yml` for users who
want that; directory-scoped `.cheat` serves the project-context use case
### Walk to filesystem root (not `$HOME`)
Rather than stopping the search at `$HOME`:
- Simpler implementation with no platform-specific home-directory detection
- Supports sysadmins working in `/etc`, `/srv`, `/var`, or other paths
outside `$HOME`
- The boundary only matters on the failure path (no `.cheat` found anywhere),
where the cost is a few extra `stat` calls
- Security is not a concern since cheatsheets are display-only text, not
executable code
## Consequences
### Positive
- Users can place `.cheat` at their project root and it works from any
subdirectory, matching their mental model
- No configuration changes needed; existing `.cheat` directories continue to
work identically
- Minimal code change (one small helper function)
### Negative
- A `.cheat` directory in an unexpected ancestor could be picked up
unintentionally, though this is unlikely in practice and matches how `.git`
works
### Neutral
- The cheatpath name remains `"cwd"` regardless of which ancestor the `.cheat`
was found in
## Alternatives Considered
### 1. Stop at `$HOME`
**Rejected**: Adds platform-specific complexity for minimal benefit. The only
downside of walking to root is a few extra `stat` calls on the failure path.
### 2. Collect multiple `.cheat` directories
**Rejected**: Introduces precedence and naming complexity. Users who want
multiple cheatpaths can configure them in `conf.yml`.
## References
- GitHub issue: #602
- Implementation: `findLocalCheatpath()` in `internal/config/new.go`

92
build/embed.go Normal file
View File

@@ -0,0 +1,92 @@
//go:build ignore
// +build ignore
// This script embeds `docopt.txt and `conf.yml` into the binary during at
// build time.
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
)
func main() {
// get the cwd
cwd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
// get the project root
root, err := filepath.Abs(cwd + "../../../")
if err != nil {
log.Fatal(err)
}
// specify template file information
type file struct {
In string
Out string
Method string
}
// enumerate the template files to process
files := []file{
file{
In: "cmd/cheat/docopt.txt",
Out: "cmd/cheat/str_usage.go",
Method: "usage"},
file{
In: "configs/conf.yml",
Out: "cmd/cheat/str_config.go",
Method: "configs"},
}
// iterate over each static file
for _, file := range files {
// delete the outfile
os.Remove(filepath.Join(root, file.Out))
// read the static template
bytes, err := ioutil.ReadFile(filepath.Join(root, file.In))
if err != nil {
log.Fatal(err)
}
// render the template
data := template(file.Method, string(bytes))
// write the file to the specified outpath
spath := filepath.Join(root, file.Out)
err = ioutil.WriteFile(spath, []byte(data), 0644)
if err != nil {
log.Fatal(err)
}
}
}
// template packages the
func template(method string, body string) string {
// specify the template string
t := `package main
// Code generated .* DO NOT EDIT.
import (
"strings"
)
func %s() string {
return strings.TrimSpace(%s)
}
`
return fmt.Sprintf(t, method, "`"+body+"`")
}

View File

@@ -3,11 +3,9 @@ package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
)
func cmdConf(_ *cobra.Command, _ []string, conf config.Config) {
func cmdConf(opts map[string]interface{}, conf config.Config) {
fmt.Println(conf.Path)
}

View File

@@ -5,14 +5,12 @@ import (
"fmt"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
)
// cmdDirectories lists the configured cheatpaths.
func cmdDirectories(_ *cobra.Command, _ []string, conf config.Config) {
func cmdDirectories(opts map[string]interface{}, conf config.Config) {
// initialize a tabwriter to produce cleanly columnized output
var out bytes.Buffer

View File

@@ -7,24 +7,15 @@ import (
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets"
)
// cmdEdit opens a cheatsheet for editing (or creates it if it doesn't exist).
func cmdEdit(cmd *cobra.Command, _ []string, conf config.Config) {
func cmdEdit(opts map[string]interface{}, conf config.Config) {
cheatsheet, _ := cmd.Flags().GetString("edit")
// validate the cheatsheet name
if err := sheet.Validate(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1)
}
cheatsheet := opts["--edit"].(string)
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
@@ -32,11 +23,12 @@ func cmdEdit(cmd *cobra.Command, _ []string, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(tagVal, ","),
strings.Split(opts["--tag"].(string), ","),
)
}
@@ -54,36 +46,55 @@ func cmdEdit(cmd *cobra.Command, _ []string, conf config.Config) {
// if the sheet exists and is not read-only, edit it in place
if ok && !sheet.ReadOnly {
editpath = sheet.Path
} else {
// for read-only or non-existent sheets, resolve a writeable path
// if the sheet exists but is read-only, copy it before editing
} else if ok && sheet.ReadOnly {
// compute the new edit path
// begin by getting a writeable cheatpath
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
os.Exit(1)
}
// use the existing title for read-only copies, the requested name otherwise
title := cheatsheet
if ok {
title = sheet.Title
}
editpath = filepath.Join(writepath.Path, title)
// compute the new edit path
editpath = filepath.Join(writepath.Path, sheet.Title)
if ok {
// copy the read-only sheet to the writeable path
// (Copy handles MkdirAll internally)
if err := sheet.Copy(editpath); err != nil {
fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err)
// create any necessary subdirectories
dirs := filepath.Dir(editpath)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
os.Exit(1)
}
} else {
// create any necessary subdirectories for the new sheet
dirs := filepath.Dir(editpath)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
os.Exit(1)
}
}
// copy the sheet to the new edit path
err = sheet.Copy(editpath)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err)
os.Exit(1)
}
// if the sheet does not exist, create it
} else {
// compute the new edit path
// begin by getting a writeable cheatpath
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
os.Exit(1)
}
// compute the new edit path
editpath = filepath.Join(writepath.Path, cheatsheet)
// create any necessary subdirectories
dirs := filepath.Dir(editpath)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
os.Exit(1)
}
}
}
@@ -93,14 +104,14 @@ func cmdEdit(cmd *cobra.Command, _ []string, conf config.Config) {
// call to `exec.Command` will fail.
parts := strings.Fields(conf.Editor)
editor := parts[0]
editorArgs := append(parts[1:], editpath)
args := append(parts[1:], editpath)
// edit the cheatsheet
editorCmd := exec.Command(editor, editorArgs...)
editorCmd.Stdout = os.Stdout
editorCmd.Stdin = os.Stdin
editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil {
cmd := exec.Command(editor, args...)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "failed to edit cheatsheet: %v\n", err)
os.Exit(1)
}

View File

@@ -3,27 +3,54 @@ package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/installer"
)
// cmdInit displays an example config file.
func cmdInit(home string, envvars map[string]string) {
func cmdInit() {
// identify the os-specific paths at which configs may be located
// get the user's home directory
home, err := homedir.Dir()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get user home directory: %v\n", err)
os.Exit(1)
}
// read the envvars into a map of strings
envvars := map[string]string{}
for _, e := range os.Environ() {
pair := strings.SplitN(e, "=", 2)
envvars[pair[0]] = pair[1]
}
// load the config template
configs := configs()
// identify the os-specifc paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err)
os.Exit(1)
}
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confpath := confpaths[0]
confdir := filepath.Dir(confpath)
// expand template placeholders and comment out community cheatpath
configs := installer.ExpandTemplate(configs(), confpath)
configs = installer.CommentCommunity(configs, confpath)
// create paths for community and personal cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal")
// template the above paths into the default configs
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
// output the templated configs
fmt.Println(configs)

View File

@@ -9,8 +9,6 @@ import (
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheet"
@@ -18,7 +16,7 @@ import (
)
// cmdList lists all available cheatsheets.
func cmdList(cmd *cobra.Command, args []string, conf config.Config) {
func cmdList(opts map[string]interface{}, conf config.Config) {
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
@@ -26,11 +24,12 @@ func cmdList(cmd *cobra.Command, args []string, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
// filter cheatsheets by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(tagVal, ","),
strings.Split(opts["--tag"].(string), ","),
)
}
@@ -50,13 +49,16 @@ func cmdList(cmd *cobra.Command, args []string, conf config.Config) {
})
// filter if <cheatsheet> was specified
if len(args) > 0 {
// NB: our docopt specification is misleading here. When used in conjunction
// with `-l`, `<cheatsheet>` is really a pattern against which to filter
// sheet titles.
if opts["<cheatsheet>"] != nil {
// initialize a slice of filtered sheets
filtered := []sheet.Sheet{}
// initialize our filter pattern
pattern := "(?i)" + args[0]
pattern := "(?i)" + opts["<cheatsheet>"].(string)
// compile the regex
reg, err := regexp.Compile(pattern)
@@ -85,18 +87,12 @@ func cmdList(cmd *cobra.Command, args []string, conf config.Config) {
var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 0, 1, ' ', 0)
// write a header row
fmt.Fprintln(w, "title:\tfile:\ttags:")
// generate sorted, columnized output
briefFlag, _ := cmd.Flags().GetBool("brief")
if briefFlag {
fmt.Fprintln(w, "title:\ttags:")
for _, sheet := range flattened {
fmt.Fprintf(w, "%s\t%s\n", sheet.Title, strings.Join(sheet.Tags, ","))
}
} else {
fmt.Fprintln(w, "title:\tfile:\ttags:")
for _, sheet := range flattened {
fmt.Fprintf(w, "%s\t%s\t%s\n", sheet.Title, sheet.Path, strings.Join(sheet.Tags, ","))
}
for _, sheet := range flattened {
fmt.Fprintf(w, "%s\t%s\t%s\n", sheet.Title, sheet.Path, strings.Join(sheet.Tags, ","))
}
// write columnized output to stdout

View File

@@ -5,23 +5,14 @@ import (
"os"
"strings"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets"
)
// cmdRemove removes (deletes) a cheatsheet.
func cmdRemove(cmd *cobra.Command, _ []string, conf config.Config) {
// cmdRemove opens a cheatsheet for editing (or creates it if it doesn't exist).
func cmdRemove(opts map[string]interface{}, conf config.Config) {
cheatsheet, _ := cmd.Flags().GetString("rm")
// validate the cheatsheet name
if err := sheet.Validate(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1)
}
cheatsheet := opts["--rm"].(string)
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
@@ -29,11 +20,12 @@ func cmdRemove(cmd *cobra.Command, _ []string, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(tagVal, ","),
strings.Split(opts["--tag"].(string), ","),
)
}

View File

@@ -6,19 +6,15 @@ import (
"regexp"
"strings"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
// cmdSearch searches for strings in cheatsheets.
func cmdSearch(cmd *cobra.Command, args []string, conf config.Config) {
func cmdSearch(opts map[string]interface{}, conf config.Config) {
phrase, _ := cmd.Flags().GetString("search")
colorize, _ := cmd.Flags().GetBool("colorize")
useRegex, _ := cmd.Flags().GetBool("regex")
phrase := opts["--search"].(string)
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
@@ -26,29 +22,15 @@ func cmdSearch(cmd *cobra.Command, args []string, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(tagVal, ","),
strings.Split(opts["--tag"].(string), ","),
)
}
// prepare the search pattern
pattern := "(?i)" + phrase
// unless --regex is provided, in which case we pass the regex unaltered
if useRegex {
pattern = phrase
}
// compile the regex once, outside the loop
reg, err := regexp.Compile(pattern)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to compile regexp: %s, %v\n", pattern, err)
os.Exit(1)
}
// iterate over each cheatpath
out := ""
for _, pathcheats := range cheatsheets {
@@ -58,10 +40,25 @@ func cmdSearch(cmd *cobra.Command, args []string, conf config.Config) {
// if <cheatsheet> was provided, constrain the search only to
// matching cheatsheets
if len(args) > 0 && sheet.Title != args[0] {
if opts["<cheatsheet>"] != nil && sheet.Title != opts["<cheatsheet>"] {
continue
}
// assume that we want to perform a case-insensitive search for <phrase>
pattern := "(?i)" + phrase
// unless --regex is provided, in which case we pass the regex unaltered
if opts["--regex"] == true {
pattern = phrase
}
// compile the regex
reg, err := regexp.Compile(pattern)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to compile regexp: %s, %v\n", pattern, err)
os.Exit(1)
}
// `Search` will return text entries that match the search terms.
// We're using it here to overwrite the prior cheatsheet Text,
// filtering it to only what is relevant.
@@ -73,7 +70,7 @@ func cmdSearch(cmd *cobra.Command, args []string, conf config.Config) {
}
// if colorization was requested, apply it here
if conf.Color(colorize) {
if conf.Color(opts) {
sheet.Colorize(conf)
}
@@ -83,7 +80,7 @@ func cmdSearch(cmd *cobra.Command, args []string, conf config.Config) {
// append the cheatsheet title
sheet.Title,
// append the cheatsheet path
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(colorize)),
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
// indent each line of content
display.Indent(sheet.Text),
)

View File

@@ -4,15 +4,13 @@ import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
// cmdTags lists all tags in use.
func cmdTags(_ *cobra.Command, _ []string, conf config.Config) {
func cmdTags(opts map[string]interface{}, conf config.Config) {
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)

View File

@@ -1,42 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/repo"
)
// cmdUpdate updates git-backed cheatpaths.
func cmdUpdate(_ *cobra.Command, _ []string, conf config.Config) {
hasError := false
for _, path := range conf.Cheatpaths {
err := repo.Pull(path.Path)
switch {
case err == nil:
fmt.Printf("%s: ok\n", path.Name)
case errors.Is(err, git.ErrRepositoryNotExists):
fmt.Printf("%s: skipped (not a git repository)\n", path.Name)
case errors.Is(err, repo.ErrDirtyWorktree):
fmt.Printf("%s: skipped (dirty worktree)\n", path.Name)
default:
fmt.Fprintf(os.Stderr, "%s: error (%v)\n", path.Name, err)
hasError = true
}
}
if hasError {
os.Exit(1)
}
}

View File

@@ -5,19 +5,15 @@ import (
"os"
"strings"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
// cmdView displays a cheatsheet for viewing.
func cmdView(cmd *cobra.Command, args []string, conf config.Config) {
func cmdView(opts map[string]interface{}, conf config.Config) {
cheatsheet := args[0]
colorize, _ := cmd.Flags().GetBool("colorize")
cheatsheet := opts["<cheatsheet>"].(string)
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
@@ -25,17 +21,17 @@ func cmdView(cmd *cobra.Command, args []string, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(tagVal, ","),
strings.Split(opts["--tag"].(string), ","),
)
}
// if --all was passed, display cheatsheets from all cheatpaths
allFlag, _ := cmd.Flags().GetBool("all")
if allFlag {
if opts["--all"].(bool) {
// iterate over the cheatpaths
out := ""
for _, cheatpath := range cheatsheets {
@@ -46,11 +42,11 @@ func cmdView(cmd *cobra.Command, args []string, conf config.Config) {
// identify the matching cheatsheet
out += fmt.Sprintf("%s %s\n",
sheet.Title,
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(colorize)),
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
)
// apply colorization if requested
if conf.Color(colorize) {
if conf.Color(opts) {
sheet.Colorize(conf)
}
@@ -77,7 +73,7 @@ func cmdView(cmd *cobra.Command, args []string, conf config.Config) {
}
// apply colorization if requested
if conf.Color(colorize) {
if conf.Color(opts) {
sheet.Colorize(conf)
}

View File

@@ -1,74 +0,0 @@
package main
// configs returns the default configuration template
func configs() string {
return `---
# The editor to use with 'cheat -e <sheet>'. Overridden by $VISUAL or $EDITOR.
editor: EDITOR_PATH
# Should 'cheat' always colorize output?
colorize: false
# Which 'chroma' colorscheme should be applied to the output?
# Options are available here:
# https://github.com/alecthomas/chroma/tree/master/styles
style: monokai
# Which 'chroma' "formatter" should be applied?
# One of: "terminal", "terminal256", "terminal16m"
formatter: terminal256
# Through which pager should output be piped?
# 'less -FRX' is recommended on Unix systems
# 'more' is recommended on Windows
pager: PAGER_PATH
# The paths at which cheatsheets are available. Tags associated with a cheatpath
# are automatically attached to all cheatsheets residing on that path.
#
# Whenever cheatsheets share the same title (like 'tar'), the most local
# cheatsheets (those which come later in this file) take precedence over the
# less local sheets. This allows you to create your own "overides" for
# "upstream" cheatsheets.
#
# But what if you want to view the "upstream" cheatsheets instead of your own?
# Cheatsheets may be filtered by 'tags' in combination with the '--tag' flag.
#
# Example: 'cheat tar --tag=community' will display the 'tar' cheatsheet that
# is tagged as 'community' rather than your own.
#
# Paths that come earlier are considered to be the most "global", and paths
# that come later are considered to be the most "local". The most "local" paths
# take precedence.
#
# See: https://github.com/cheat/cheat/blob/master/doc/cheat.1.md#cheatpaths
cheatpaths:
# Cheatsheets that are tagged "personal" are stored here by default:
- name: personal
path: PERSONAL_PATH
tags: [ personal ]
readonly: false
# Cheatsheets that are tagged "work" are stored here by default:
- name: work
path: WORK_PATH
tags: [ work ]
readonly: false
# Community cheatsheets (https://github.com/cheat/cheatsheets):
# To install: git clone https://github.com/cheat/cheatsheets COMMUNITY_PATH
- name: community
path: COMMUNITY_PATH
tags: [ community ]
readonly: true
# You can also use glob patterns to automatically load cheatsheets from all
# directories that match.
#
# Example: overload cheatsheets for projects under ~/src/github.com/example/*/
#- name: example-projects
# path: ~/src/github.com/example/**/.cheat
# tags: [ example ]
# readonly: true`
}

59
cmd/cheat/docopt.txt Normal file
View File

@@ -0,0 +1,59 @@
Usage:
cheat [options] [<cheatsheet>]
Options:
--init Write a default config file to stdout
-a --all Search among all cheatpaths
-c --colorize Colorize output
-d --directories List cheatsheet directories
-e --edit=<cheatsheet> Edit <cheatsheet>
-l --list List cheatsheets
-p --path=<name> Return only sheets found on cheatpath <name>
-r --regex Treat search <phrase> as a regex
-s --search=<phrase> Search cheatsheets for <phrase>
-t --tag=<tag> Return only sheets matching <tag>
-T --tags List all tags in use
-v --version Print the version number
--rm=<cheatsheet> Remove (delete) <cheatsheet>
--conf Display the config file path
Examples:
To initialize a config file:
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
To view the tar cheatsheet:
cheat tar
To edit (or create) the foo cheatsheet:
cheat -e foo
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
cheat -p work -e foo/bar
To view all cheatsheet directories:
cheat -d
To list all available cheatsheets:
cheat -l
To list all cheatsheets whose titles match "apt":
cheat -l apt
To list all tags in use:
cheat -T
To list available cheatsheets that are tagged as "personal":
cheat -l -t personal
To search for "ssh" among all cheatsheets, and colorize matches:
cheat -c -s ssh
To search (by regex) for cheatsheets that contain an IP address:
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
To remove (delete) the foo/bar cheatsheet:
cheat --rm foo/bar
To view the configuration file path:
cheat --conf

View File

@@ -1,148 +1,39 @@
// Package main serves as the executable entrypoint.
package main
//go:generate go run ../../build/embed.go
import (
"fmt"
"os"
"runtime"
"strings"
"github.com/docopt/docopt-go"
"github.com/mitchellh/go-homedir"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/completions"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/installer"
)
const version = "5.1.0"
var rootCmd = &cobra.Command{
Use: "cheat [cheatsheet]",
Short: "Create and view interactive cheatsheets on the command-line",
Long: `cheat allows you to create and view interactive cheatsheets on the
command-line. It was designed to help remind *nix system administrators of
options for commands that they use frequently, but not frequently enough to
remember.`,
Example: ` To initialize a config file:
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
To view the tar cheatsheet:
cheat tar
To edit (or create) the foo cheatsheet:
cheat -e foo
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
cheat -p work -e foo/bar
To view all cheatsheet directories:
cheat -d
To list all available cheatsheets:
cheat -l
To briefly list all cheatsheets whose titles match "apt":
cheat -b apt
To list all tags in use:
cheat -T
To list available cheatsheets that are tagged as "personal":
cheat -l -t personal
To search for "ssh" among all cheatsheets, and colorize matches:
cheat -c -s ssh
To search (by regex) for cheatsheets that contain an IP address:
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
To remove (delete) the foo/bar cheatsheet:
cheat --rm foo/bar
To update all git-backed cheatpaths:
cheat --update
To view the configuration file path:
cheat --conf
To generate shell completions (bash, zsh, fish, powershell):
cheat --completion bash`,
RunE: run,
Args: cobra.MaximumNArgs(1),
SilenceErrors: true,
SilenceUsage: true,
ValidArgsFunction: completions.Cheatsheets,
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
}
func init() {
f := rootCmd.Flags()
// bool flags
f.BoolP("all", "a", false, "Search among all cheatpaths")
f.BoolP("brief", "b", false, "List cheatsheets without file paths")
f.BoolP("colorize", "c", false, "Colorize output")
f.BoolP("directories", "d", false, "List cheatsheet directories")
f.Bool("init", false, "Write a default config file to stdout")
f.BoolP("list", "l", false, "List cheatsheets")
f.BoolP("regex", "r", false, "Treat search <phrase> as a regex")
f.BoolP("tags", "T", false, "List all tags in use")
f.BoolP("update", "u", false, "Update git-backed cheatpaths")
f.BoolP("version", "v", false, "Print the version number")
f.Bool("conf", false, "Display the config file path")
// string flags
f.StringP("edit", "e", "", "Edit `cheatsheet`")
f.StringP("path", "p", "", "Return only sheets found on cheatpath `name`")
f.StringP("search", "s", "", "Search cheatsheets for `phrase`")
f.StringP("tag", "t", "", "Return only sheets matching `tag`")
f.String("rm", "", "Remove (delete) `cheatsheet`")
f.String("completion", "", "Generate shell completion script (`shell`: bash, zsh, fish, powershell)")
// register flag completion functions
rootCmd.RegisterFlagCompletionFunc("tag", completions.Tags)
rootCmd.RegisterFlagCompletionFunc("path", completions.Paths)
rootCmd.RegisterFlagCompletionFunc("edit", completions.Cheatsheets)
rootCmd.RegisterFlagCompletionFunc("rm", completions.Cheatsheets)
}
const version = "4.4.0"
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
// initialize options
opts, err := docopt.ParseArgs(usage(), nil, version)
if err != nil {
// panic here, because this should never happen
panic(fmt.Errorf("docopt failed to parse: %v", err))
}
}
func run(cmd *cobra.Command, args []string) error {
f := cmd.Flags()
// handle --init early (no config needed)
if initFlag, _ := f.GetBool("init"); initFlag {
home, err := homedir.Dir()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get user home directory: %v\n", err)
os.Exit(1)
}
envvars := config.EnvVars()
cmdInit(home, envvars)
// if --init was passed, we don't want to attempt to load a config file.
// Instead, just execute cmd_init and exit
if opts["--init"] != nil && opts["--init"] == true {
cmdInit()
os.Exit(0)
}
// handle --version early
if versionFlag, _ := f.GetBool("version"); versionFlag {
fmt.Println(version)
os.Exit(0)
}
// handle --completion early (no config needed)
if f.Changed("completion") {
shell, _ := f.GetString("completion")
return completions.Generate(cmd, shell, os.Stdout)
}
// get the user's home directory
home, err := homedir.Dir()
if err != nil {
@@ -151,9 +42,16 @@ func run(cmd *cobra.Command, args []string) error {
}
// read the envvars into a map of strings
envvars := config.EnvVars()
envvars := map[string]string{}
for _, e := range os.Environ() {
pair := strings.SplitN(e, "=", 2)
if runtime.GOOS == "windows" {
pair[0] = strings.ToUpper(pair[0])
}
envvars[pair[0]] = pair[1]
}
// identify the os-specific paths at which configs may be located
// identify the os-specifc paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
@@ -194,7 +92,7 @@ func run(cmd *cobra.Command, args []string) error {
}
// initialize the configs
conf, err := config.New(confpath, true)
conf, err := config.New(opts, confpath, true)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
@@ -207,11 +105,10 @@ func run(cmd *cobra.Command, args []string) error {
}
// filter the cheatpaths if --path was passed
if f.Changed("path") {
pathVal, _ := f.GetString("path")
if opts["--path"] != nil {
conf.Cheatpaths, err = cheatpath.Filter(
conf.Cheatpaths,
pathVal,
opts["--path"].(string),
)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid option --path: %v\n", err)
@@ -220,48 +117,41 @@ func run(cmd *cobra.Command, args []string) error {
}
// determine which command to execute
confFlag, _ := f.GetBool("conf")
dirFlag, _ := f.GetBool("directories")
listFlag, _ := f.GetBool("list")
briefFlag, _ := f.GetBool("brief")
tagsFlag, _ := f.GetBool("tags")
updateFlag, _ := f.GetBool("update")
tagVal, _ := f.GetString("tag")
var cmd func(map[string]interface{}, config.Config)
switch {
case confFlag:
cmdConf(cmd, args, conf)
case opts["--conf"].(bool):
cmd = cmdConf
case dirFlag:
cmdDirectories(cmd, args, conf)
case opts["--directories"].(bool):
cmd = cmdDirectories
case f.Changed("edit"):
cmdEdit(cmd, args, conf)
case opts["--edit"] != nil:
cmd = cmdEdit
case listFlag, briefFlag:
cmdList(cmd, args, conf)
case opts["--list"].(bool):
cmd = cmdList
case tagsFlag:
cmdTags(cmd, args, conf)
case opts["--tags"].(bool):
cmd = cmdTags
case updateFlag:
cmdUpdate(cmd, args, conf)
case opts["--search"] != nil:
cmd = cmdSearch
case f.Changed("search"):
cmdSearch(cmd, args, conf)
case opts["--rm"] != nil:
cmd = cmdRemove
case f.Changed("rm"):
cmdRemove(cmd, args, conf)
case opts["<cheatsheet>"] != nil:
cmd = cmdView
case len(args) > 0:
cmdView(cmd, args, conf)
case tagVal != "":
cmdList(cmd, args, conf)
case opts["--tag"] != nil && opts["--tag"].(string) != "":
cmd = cmdList
default:
return cmd.Help()
fmt.Println(usage())
os.Exit(0)
}
return nil
// execute the command
cmd(opts, conf)
}

93
cmd/cheat/str_config.go Normal file
View File

@@ -0,0 +1,93 @@
package main
// Code generated .* DO NOT EDIT.
import (
"strings"
)
func configs() string {
return strings.TrimSpace(`---
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL.
editor: EDITOR_PATH
# Should 'cheat' always colorize output?
colorize: false
# Which 'chroma' colorscheme should be applied to the output?
# Options are available here:
# https://github.com/alecthomas/chroma/tree/master/styles
style: monokai
# Which 'chroma' "formatter" should be applied?
# One of: "terminal", "terminal256", "terminal16m"
formatter: terminal256
# Through which pager should output be piped?
# 'less -FRX' is recommended on Unix systems
# 'more' is recommended on Windows
pager: PAGER_PATH
# Cheatpaths are paths at which cheatsheets are available on your local
# filesystem.
#
# It is useful to sort cheatsheets into different cheatpaths for organizational
# purposes. For example, you might want one cheatpath for community
# cheatsheets, one for personal cheatsheets, one for cheatsheets pertaining to
# your day job, one for code snippets, etc.
#
# Cheatpaths are scoped, such that more "local" cheatpaths take priority over
# more "global" cheatpaths. (The most global cheatpath is listed first in this
# file; the most local is listed last.) For example, if there is a 'tar'
# cheatsheet on both global and local paths, you'll be presented with the local
# one by default. ('cheat -p' can be used to view cheatsheets from alternative
# cheatpaths.)
#
# Cheatpaths can also be tagged as "read only". This instructs cheat not to
# automatically create cheatsheets on a read-only cheatpath. Instead, when you
# would like to edit a read-only cheatsheet using 'cheat -e', cheat will
# perform a copy-on-write of that cheatsheet from a read-only cheatpath to a
# writeable cheatpath.
#
# This is very useful when you would like to maintain, for example, a
# "pristine" repository of community cheatsheets on one cheatpath, and an
# editable personal reponsity of cheatsheets on another cheatpath.
#
# Cheatpaths can be also configured to automatically apply tags to cheatsheets
# on certain paths, which can be useful for querying purposes.
# Example: 'cheat -t work jenkins'.
#
# Community cheatsheets must be installed separately, though you may have
# downloaded them automatically when installing 'cheat'. If not, you may
# download them here:
#
# https://github.com/cheat/cheatsheets
cheatpaths:
# Cheatpath properties mean the following:
# 'name': the name of the cheatpath (view with 'cheat -d', filter with 'cheat -p')
# 'path': the filesystem path of the cheatsheet directory (view with 'cheat -d')
# 'tags': tags that should be automatically applied to sheets on this path
# 'readonly': shall user-created ('cheat -e') cheatsheets be saved here?
- name: community
path: COMMUNITY_PATH
tags: [ community ]
readonly: true
# If you have personalized cheatsheets, list them last. They will take
# precedence over the more global cheatsheets.
- name: personal
path: PERSONAL_PATH
tags: [ personal ]
readonly: false
# While it requires no configuration here, it's also worth noting that
# cheat will automatically append directories named '.cheat' within the
# current working directory to the 'cheatpath'. This can be very useful if
# you'd like to closely associate cheatsheets with, for example, a directory
# containing source code.
#
# Such "directory-scoped" cheatsheets will be treated as the most "local"
# cheatsheets, and will override less "local" cheatsheets. Similarly,
# directory-scoped cheatsheets will always be editable ('readonly: false').
`)
}

70
cmd/cheat/str_usage.go Normal file
View File

@@ -0,0 +1,70 @@
package main
// Code generated .* DO NOT EDIT.
import (
"strings"
)
func usage() string {
return strings.TrimSpace(`Usage:
cheat [options] [<cheatsheet>]
Options:
--init Write a default config file to stdout
-a --all Search among all cheatpaths
-c --colorize Colorize output
-d --directories List cheatsheet directories
-e --edit=<cheatsheet> Edit <cheatsheet>
-l --list List cheatsheets
-p --path=<name> Return only sheets found on cheatpath <name>
-r --regex Treat search <phrase> as a regex
-s --search=<phrase> Search cheatsheets for <phrase>
-t --tag=<tag> Return only sheets matching <tag>
-T --tags List all tags in use
-v --version Print the version number
--rm=<cheatsheet> Remove (delete) <cheatsheet>
--conf Display the config file path
Examples:
To initialize a config file:
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
To view the tar cheatsheet:
cheat tar
To edit (or create) the foo cheatsheet:
cheat -e foo
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
cheat -p work -e foo/bar
To view all cheatsheet directories:
cheat -d
To list all available cheatsheets:
cheat -l
To list all cheatsheets whose titles match "apt":
cheat -l apt
To list all tags in use:
cheat -T
To list available cheatsheets that are tagged as "personal":
cheat -l -t personal
To search for "ssh" among all cheatsheets, and colorize matches:
cheat -c -s ssh
To search (by regex) for cheatsheets that contain an IP address:
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
To remove (delete) the foo/bar cheatsheet:
cheat --rm foo/bar
To view the configuration file path:
cheat --conf
`)
}

82
configs/conf.yml Normal file
View File

@@ -0,0 +1,82 @@
---
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL.
editor: EDITOR_PATH
# Should 'cheat' always colorize output?
colorize: false
# Which 'chroma' colorscheme should be applied to the output?
# Options are available here:
# https://github.com/alecthomas/chroma/tree/master/styles
style: monokai
# Which 'chroma' "formatter" should be applied?
# One of: "terminal", "terminal256", "terminal16m"
formatter: terminal256
# Through which pager should output be piped?
# 'less -FRX' is recommended on Unix systems
# 'more' is recommended on Windows
pager: PAGER_PATH
# Cheatpaths are paths at which cheatsheets are available on your local
# filesystem.
#
# It is useful to sort cheatsheets into different cheatpaths for organizational
# purposes. For example, you might want one cheatpath for community
# cheatsheets, one for personal cheatsheets, one for cheatsheets pertaining to
# your day job, one for code snippets, etc.
#
# Cheatpaths are scoped, such that more "local" cheatpaths take priority over
# more "global" cheatpaths. (The most global cheatpath is listed first in this
# file; the most local is listed last.) For example, if there is a 'tar'
# cheatsheet on both global and local paths, you'll be presented with the local
# one by default. ('cheat -p' can be used to view cheatsheets from alternative
# cheatpaths.)
#
# Cheatpaths can also be tagged as "read only". This instructs cheat not to
# automatically create cheatsheets on a read-only cheatpath. Instead, when you
# would like to edit a read-only cheatsheet using 'cheat -e', cheat will
# perform a copy-on-write of that cheatsheet from a read-only cheatpath to a
# writeable cheatpath.
#
# This is very useful when you would like to maintain, for example, a
# "pristine" repository of community cheatsheets on one cheatpath, and an
# editable personal reponsity of cheatsheets on another cheatpath.
#
# Cheatpaths can be also configured to automatically apply tags to cheatsheets
# on certain paths, which can be useful for querying purposes.
# Example: 'cheat -t work jenkins'.
#
# Community cheatsheets must be installed separately, though you may have
# downloaded them automatically when installing 'cheat'. If not, you may
# download them here:
#
# https://github.com/cheat/cheatsheets
cheatpaths:
# Cheatpath properties mean the following:
# 'name': the name of the cheatpath (view with 'cheat -d', filter with 'cheat -p')
# 'path': the filesystem path of the cheatsheet directory (view with 'cheat -d')
# 'tags': tags that should be automatically applied to sheets on this path
# 'readonly': shall user-created ('cheat -e') cheatsheets be saved here?
- name: community
path: COMMUNITY_PATH
tags: [ community ]
readonly: true
# If you have personalized cheatsheets, list them last. They will take
# precedence over the more global cheatsheets.
- name: personal
path: PERSONAL_PATH
tags: [ personal ]
readonly: false
# While it requires no configuration here, it's also worth noting that
# cheat will automatically append directories named '.cheat' within the
# current working directory to the 'cheatpath'. This can be very useful if
# you'd like to closely associate cheatsheets with, for example, a directory
# containing source code.
#
# Such "directory-scoped" cheatsheets will be treated as the most "local"
# cheatsheets, and will override less "local" cheatsheets. Similarly,
# directory-scoped cheatsheets will always be editable ('readonly: false').

View File

@@ -1,202 +1,208 @@
.\" Automatically generated by Pandoc 3.1.11.1
.\" Automatically generated by Pandoc 2.2.1
.\"
.TH "CHEAT" "1" "" "" "General Commands Manual"
.hy
.SH NAME
\f[B]cheat\f[R] \[em] create and view command\-line cheatsheets
.PP
\f[B]cheat\f[] \[em] create and view command\-line cheatsheets
.SH SYNOPSIS
.PP
\f[B]cheat\f[R] [options] [\f[I]CHEATSHEET\f[R]]
\f[B]cheat\f[] [options] [\f[I]CHEATSHEET\f[]]
.SH DESCRIPTION
\f[B]cheat\f[R] allows you to create and view interactive cheatsheets on
.PP
\f[B]cheat\f[] allows you to create and view interactive cheatsheets on
the command\-line.
It was designed to help remind *nix system administrators of options for
commands that they use frequently, but not frequently enough to
remember.
.SH OPTIONS
.TP
\[en]init
.B \[en]init
Print a config file to stdout.
.RS
.RE
.TP
\[en]conf
Display the config file path.
.TP
\-a, \[en]all
Search among all cheatpaths.
.TP
\-b, \[en]brief
List cheatsheets without file paths.
.TP
\-c, \[en]colorize
.B \-c, \[en]colorize
Colorize output.
.RS
.RE
.TP
\-d, \[en]directories
.B \-d, \[en]directories
List cheatsheet directories.
.RS
.RE
.TP
\-e, \[en]edit=\f[I]CHEATSHEET\f[R]
Open \f[I]CHEATSHEET\f[R] for editing.
.B \-e, \[en]edit=\f[I]CHEATSHEET\f[]
Open \f[I]CHEATSHEET\f[] for editing.
.RS
.RE
.TP
\-l, \[en]list
.B \-l, \[en]list
List available cheatsheets.
.RS
.RE
.TP
\-p, \[en]path=\f[I]PATH\f[R]
Filter only to sheets found on path \f[I]PATH\f[R].
.B \-p, \[en]path=\f[I]PATH\f[]
Filter only to sheets found on path \f[I]PATH\f[].
.RS
.RE
.TP
\-r, \[en]regex
Treat search \f[I]PHRASE\f[R] as a regular expression.
.B \-r, \[en]regex
Treat search \f[I]PHRASE\f[] as a regular expression.
.RS
.RE
.TP
\-s, \[en]search=\f[I]PHRASE\f[R]
Search cheatsheets for \f[I]PHRASE\f[R].
.B \-s, \[en]search=\f[I]PHRASE\f[]
Search cheatsheets for \f[I]PHRASE\f[].
.RS
.RE
.TP
\-t, \[en]tag=\f[I]TAG\f[R]
Filter only to sheets tagged with \f[I]TAG\f[R].
.B \-t, \[en]tag=\f[I]TAG\f[]
Filter only to sheets tagged with \f[I]TAG\f[].
.RS
.RE
.TP
\-T, \[en]tags
.B \-T, \[en]tags
List all tags in use.
.RS
.RE
.TP
\-u, \[en]update
Update git\-backed cheatpaths by pulling the latest changes.
.TP
\-v, \[en]version
.B \-v, \[en]version
Print the version number.
.RS
.RE
.TP
\[en]rm=\f[I]CHEATSHEET\f[R]
Remove (deletes) \f[I]CHEATSHEET\f[R].
.TP
\[en]completion=\f[I]SHELL\f[R]
Generate a shell completion script.
\f[I]SHELL\f[R] must be one of: \f[B]bash\f[R], \f[B]zsh\f[R],
\f[B]fish\f[R], \f[B]powershell\f[R].
.B \[en]rm=\f[I]CHEATSHEET\f[]
Remove (deletes) \f[I]CHEATSHEET\f[].
.RS
.RE
.SH EXAMPLES
.TP
To view the foo cheatsheet:
cheat \f[I]foo\f[R]
.B To view the foo cheatsheet:
cheat \f[I]foo\f[]
.RS
.RE
.TP
To edit (or create) the foo cheatsheet:
cheat \-e \f[I]foo\f[R]
.B To edit (or create) the foo cheatsheet:
cheat \-e \f[I]foo\f[]
.RS
.RE
.TP
To edit (or create) the foo/bar cheatsheet on the `work' cheatpath:
cheat \-p \f[I]work\f[R] \-e \f[I]foo/bar\f[R]
.B To edit (or create) the foo/bar cheatsheet on the `work' cheatpath:
cheat \-p \f[I]work\f[] \-e \f[I]foo/bar\f[]
.RS
.RE
.TP
To view all cheatsheet directories:
.B To view all cheatsheet directories:
cheat \-d
.RS
.RE
.TP
To list all available cheatsheets:
.B To list all available cheatsheets:
cheat \-l
.RS
.RE
.TP
To briefly list all cheatsheets whose titles match `apt':
cheat \-b \f[I]apt\f[R]
.B To list all cheatsheets whose titles match `apt':
cheat \-l \f[I]apt\f[]
.RS
.RE
.TP
To list all tags in use:
.B To list all tags in use:
cheat \-T
.RS
.RE
.TP
To list available cheatsheets that are tagged as `personal':
cheat \-l \-t \f[I]personal\f[R]
.B To list available cheatsheets that are tagged as `personal':
cheat \-l \-t \f[I]personal\f[]
.RS
.RE
.TP
To search for `ssh' among all cheatsheets, and colorize matches:
cheat \-c \-s \f[I]ssh\f[R]
.B To search for `ssh' among all cheatsheets, and colorize matches:
cheat \-c \-s \f[I]ssh\f[]
.RS
.RE
.TP
To search (by regex) for cheatsheets that contain an IP address:
cheat \-c \-r \-s \f[I]`(?:[0\-9]{1,3}.){3}[0\-9]{1,3}'\f[R]
.B To search (by regex) for cheatsheets that contain an IP address:
cheat \-c \-r \-s \f[I]`(?:[0\-9]{1,3}.){3}[0\-9]{1,3}'\f[]
.RS
.RE
.TP
To remove (delete) the foo/bar cheatsheet:
cheat \[en]rm \f[I]foo/bar\f[R]
.TP
To update all git\-backed cheatpaths:
cheat \[en]update
.TP
To update only the `community' cheatpath:
cheat \-u \-p \f[I]community\f[R]
.TP
To view the configuration file path:
cheat \[en]conf
.B To remove (delete) the foo/bar cheatsheet:
cheat \[en]rm \f[I]foo/bar\f[]
.RS
.RE
.SH FILES
.SS Configuration
\f[B]cheat\f[R] is configured via a YAML file that is conventionally
named \f[I]conf.yml\f[R].
\f[B]cheat\f[R] will search for \f[I]conf.yml\f[R] in varying locations,
.PP
\f[B]cheat\f[] is configured via a YAML file that is conventionally
named \f[I]conf.yaml\f[].
\f[B]cheat\f[] will search for \f[I]conf.yaml\f[] in varying locations,
depending upon your platform:
.SS Linux, OSX, and other Unixes
.IP "1." 3
\f[B]CHEAT_CONFIG_PATH\f[R]
\f[B]CHEAT_CONFIG_PATH\f[]
.IP "2." 3
\f[B]XDG_CONFIG_HOME\f[R]/cheat/conf.yml
\f[B]XDG_CONFIG_HOME\f[]/cheat/conf.yaml
.IP "3." 3
\f[B]$HOME\f[R]/.config/cheat/conf.yml
\f[B]$HOME\f[]/.config/cheat/conf.yml
.IP "4." 3
\f[B]$HOME\f[R]/.cheat/conf.yml
.IP "5." 3
/etc/cheat/conf.yml
\f[B]$HOME\f[]/.cheat/conf.yml
.SS Windows
.IP "1." 3
\f[B]CHEAT_CONFIG_PATH\f[R]
\f[B]CHEAT_CONFIG_PATH\f[]
.IP "2." 3
\f[B]APPDATA\f[R]/cheat/conf.yml
\f[B]APPDATA\f[]/cheat/conf.yml
.IP "3." 3
\f[B]PROGRAMDATA\f[R]/cheat/conf.yml
\f[B]PROGRAMDATA\f[]/cheat/conf.yml
.PP
\f[B]cheat\f[R] will search in the order specified above.
The first \f[I]conf.yml\f[R] encountered will be respected.
\f[B]cheat\f[] will search in the order specified above.
The first \f[I]conf.yaml\f[] encountered will be respected.
.PP
If \f[B]cheat\f[R] cannot locate a config file, it will ask if you\[cq]d
like to generate one automatically.
If \f[B]cheat\f[] cannot locate a config file, it will ask if you'd like
to generate one automatically.
Alternatively, you may also generate a config file manually by running
\f[B]cheat \[en]init\f[R] and saving its output to the appropriate
\f[B]cheat \[en]init\f[] and saving its output to the appropriate
location for your platform.
.SS Cheatpaths
\f[B]cheat\f[R] reads its cheatsheets from \[lq]cheatpaths\[rq], which
.PP
\f[B]cheat\f[] reads its cheatsheets from \[lq]cheatpaths\[rq], which
are the directories in which cheatsheets are stored.
Cheatpaths may be configured in \f[I]conf.yml\f[R], and viewed via
\f[B]cheat \-d\f[R].
Cheatpaths may be configured in \f[I]conf.yaml\f[], and viewed via
\f[B]cheat \-d\f[].
.PP
For detailed instructions on how to configure cheatpaths, please refer
to the comments in conf.yml.
.SS Autocompletion
\f[B]cheat\f[R] can generate shell completion scripts for
\f[B]bash\f[R], \f[B]zsh\f[R], \f[B]fish\f[R], and \f[B]powershell\f[R]
via the \f[B]\[en]completion\f[R] flag:
.IP
.EX
cheat \-\-completion bash
cheat \-\-completion zsh
cheat \-\-completion fish
cheat \-\-completion powershell
.EE
.PP
Completions are dynamically generated and include cheatsheet names,
tags, and cheatpath names.
Autocompletion scripts for \f[B]bash\f[], \f[B]zsh\f[], and
\f[B]fish\f[] are available for download:
.IP \[bu] 2
<https://github.com/cheat/cheat/blob/master/scripts/cheat.bash>
.IP \[bu] 2
<https://github.com/cheat/cheat/blob/master/scripts/cheat.fish>
.IP \[bu] 2
<https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh>
.PP
To install completions, pipe the output to the appropriate location for
your shell.
For example, on \f[B]bash\f[R]:
.IP
.EX
cheat \-\-completion bash > /etc/bash_completion.d/cheat
.EE
The \f[B]bash\f[] and \f[B]zsh\f[] scripts provide optional integration
with \f[B]fzf\f[], if the latter is available on your \f[B]PATH\f[].
.PP
Or for the current user only:
.IP
.EX
cheat \-\-completion bash > \[ti]/.local/share/bash\-completion/completions/cheat
.EE
.PP
For \f[B]zsh\f[R], you may need to add the completions directory to your
\f[B]fpath\f[R]:
.IP
.EX
cheat \-\-completion zsh > \[dq]${fpath[1]}/_cheat\[dq]
.EE
.PP
For \f[B]fish\f[R]:
.IP
.EX
cheat \-\-completion fish > \[ti]/.config/fish/completions/cheat.fish
.EE
The installation process will vary per system and shell configuration,
and thus will not be discussed here.
.SH ENVIRONMENT
.TP
\f[B]CHEAT_CONFIG_PATH\f[R]
.B \f[B]CHEAT_CONFIG_PATH\f[]
The path at which the config file is available.
If \f[B]CHEAT_CONFIG_PATH\f[R] is set, all other config paths will be
If \f[B]CHEAT_CONFIG_PATH\f[] is set, all other config paths will be
ignored.
.RS
.RE
.TP
.B \f[B]CHEAT_USE_FZF\f[]
If set, autocompletion scripts will attempt to integrate with
\f[B]fzf\f[].
.RS
.RE
.SH RETURN VALUES
.IP "0." 3
Successful termination
@@ -205,12 +211,11 @@ Application error
.IP "2." 3
Cheatsheet(s) not found
.SH BUGS
See GitHub issues: \c
.UR https://github.com/cheat/cheat/issues
.UE \c
.PP
See GitHub issues: <https://github.com/cheat/cheat/issues>
.SH AUTHOR
Christopher Allen Lane \c
.MT chris@chris-allen-lane.com
.ME \c
.PP
Christopher Allen Lane <chris@chris-allen-lane.com>
.SH SEE ALSO
\f[B]fzf(1)\f[R]
.PP
\f[B]fzf(1)\f[]

View File

@@ -23,15 +23,6 @@ OPTIONS
--init
: Print a config file to stdout.
--conf
: Display the config file path.
-a, --all
: Search among all cheatpaths.
-b, --brief
: List cheatsheets without file paths.
-c, --colorize
: Colorize output.
@@ -59,19 +50,12 @@ OPTIONS
-T, --tags
: List all tags in use.
-u, --update
: Update git-backed cheatpaths by pulling the latest changes.
-v, --version
: Print the version number.
--rm=_CHEATSHEET_
: Remove (deletes) _CHEATSHEET_.
--completion=_SHELL_
: Generate a shell completion script. _SHELL_ must be one of: **bash**,
**zsh**, **fish**, **powershell**.
EXAMPLES
========
@@ -91,8 +75,8 @@ To view all cheatsheet directories:
To list all available cheatsheets:
: cheat -l
To briefly list all cheatsheets whose titles match 'apt':
: cheat -b _apt_
To list all cheatsheets whose titles match 'apt':
: cheat -l _apt_
To list all tags in use:
: cheat -T
@@ -109,15 +93,6 @@ To search (by regex) for cheatsheets that contain an IP address:
To remove (delete) the foo/bar cheatsheet:
: cheat --rm _foo/bar_
To update all git-backed cheatpaths:
: cheat --update
To update only the 'community' cheatpath:
: cheat -u -p _community_
To view the configuration file path:
: cheat --conf
FILES
=====
@@ -125,16 +100,15 @@ FILES
Configuration
-------------
**cheat** is configured via a YAML file that is conventionally named
_conf.yml_. **cheat** will search for _conf.yml_ in varying locations,
_conf.yaml_. **cheat** will search for _conf.yaml_ in varying locations,
depending upon your platform:
### Linux, OSX, and other Unixes ###
1. **CHEAT_CONFIG_PATH**
2. **XDG_CONFIG_HOME**/cheat/conf.yml
2. **XDG_CONFIG_HOME**/cheat/conf.yaml
3. **$HOME**/.config/cheat/conf.yml
4. **$HOME**/.cheat/conf.yml
5. /etc/cheat/conf.yml
### Windows ###
@@ -142,7 +116,7 @@ depending upon your platform:
2. **APPDATA**/cheat/conf.yml
3. **PROGRAMDATA**/cheat/conf.yml
**cheat** will search in the order specified above. The first _conf.yml_
**cheat** will search in the order specified above. The first _conf.yaml_
encountered will be respected.
If **cheat** cannot locate a config file, it will ask if you'd like to generate
@@ -154,7 +128,7 @@ for your platform.
Cheatpaths
----------
**cheat** reads its cheatsheets from "cheatpaths", which are the directories in
which cheatsheets are stored. Cheatpaths may be configured in _conf.yml_, and
which cheatsheets are stored. Cheatpaths may be configured in _conf.yaml_, and
viewed via **cheat -d**.
For detailed instructions on how to configure cheatpaths, please refer to the
@@ -163,33 +137,18 @@ comments in conf.yml.
Autocompletion
--------------
**cheat** can generate shell completion scripts for **bash**, **zsh**,
**fish**, and **powershell** via the **--completion** flag:
Autocompletion scripts for **bash**, **zsh**, and **fish** are available for
download:
cheat --completion bash
cheat --completion zsh
cheat --completion fish
cheat --completion powershell
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.bash>
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.fish>
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh>
Completions are dynamically generated and include cheatsheet names, tags, and
cheatpath names.
The **bash** and **zsh** scripts provide optional integration with **fzf**, if
the latter is available on your **PATH**.
To install completions, pipe the output to the appropriate location for your
shell. For example, on **bash**:
cheat --completion bash > /etc/bash_completion.d/cheat
Or for the current user only:
cheat --completion bash > ~/.local/share/bash-completion/completions/cheat
For **zsh**, you may need to add the completions directory to your **fpath**:
cheat --completion zsh > "${fpath[1]}/_cheat"
For **fish**:
cheat --completion fish > ~/.config/fish/completions/cheat.fish
The installation process will vary per system and shell configuration, and thus
will not be discussed here.
ENVIRONMENT
@@ -200,6 +159,10 @@ ENVIRONMENT
: The path at which the config file is available. If **CHEAT_CONFIG_PATH** is
set, all other config paths will be ignored.
**CHEAT_USE_FZF**
: If set, autocompletion scripts will attempt to integrate with **fzf**.
RETURN VALUES
=============

48
go.mod
View File

@@ -1,39 +1,35 @@
module github.com/cheat/cheat
go 1.26
go 1.19
require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/alecthomas/chroma v0.10.0
github.com/davecgh/go-spew v1.1.1
github.com/go-git/go-git/v5 v5.16.5
github.com/mattn/go-isatty v0.0.20
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/go-git/go-git/v5 v5.4.2
github.com/mattn/go-isatty v0.0.16
github.com/mitchellh/go-homedir v1.1.0
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
gopkg.in/yaml.v2 v2.4.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 // indirect
github.com/acomagu/bufpipe v1.0.3 // indirect
github.com/cloudflare/circl v1.2.0 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.3.1 // indirect
github.com/imdario/mergo v0.3.13 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.5.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/xanzy/ssh-agent v0.3.2 // indirect
golang.org/x/crypto v0.1.0 // indirect
golang.org/x/mod v0.6.0 // indirect
golang.org/x/net v0.1.0 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/tools v0.2.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

186
go.sum
View File

@@ -1,128 +1,146 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
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.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 h1:ra2OtmuW0AE5csawV4YXMNGNQQXvLRps3z2Z59OPO+I=
github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4/go.mod h1:UBYPn8k0D56RtnR8RFQMjmh4KrZzWJ5o7Z9SYjossQ8=
github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
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/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bwesterb/go-ristretto v1.2.1/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.2.0 h1:NheeISPSUcYftKlfrLuOo4T62FkmD4t4jviLfFFYaec=
github.com/cloudflare/circl v1.2.0/go.mod h1:Ch2UgYr6ti2KTtlejELlROl0YIYj7SLjAC8M+INXlMk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
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.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
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-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8=
github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
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/kevinburke/ssh_config v1.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA=
github.com/kevinburke/ssh_config v1.5.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
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/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
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/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
github.com/xanzy/ssh-agent v0.3.2 h1:eKj4SX2Fe7mui28ZgnFW5fmTz1EIr7ugo5s6wDxdHBM=
github.com/xanzy/ssh-agent v0.3.2/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
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-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/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-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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-20200227125254-8fa46927fb4f/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/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.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,9 +1,7 @@
// Package cheatpath implements functions pertaining to cheatsheet file path
// management.
package cheatpath
// Path encapsulates cheatsheet path information
type Path struct {
// Cheatpath encapsulates cheatsheet path information
type Cheatpath struct {
Name string `yaml:"name"`
Path string `yaml:"path"`
ReadOnly bool `yaml:"readonly"`

View File

@@ -1,90 +0,0 @@
package cheatpath
import (
"strings"
"testing"
)
func TestCheatpathValidate(t *testing.T) {
tests := []struct {
name string
cheatpath Path
wantErr bool
errMsg string
}{
{
name: "valid cheatpath",
cheatpath: Path{
Name: "personal",
Path: "/home/user/.config/cheat/personal",
ReadOnly: false,
Tags: []string{"personal"},
},
wantErr: false,
},
{
name: "empty name",
cheatpath: Path{
Name: "",
Path: "/home/user/.config/cheat/personal",
ReadOnly: false,
Tags: []string{"personal"},
},
wantErr: true,
errMsg: "cheatpath name cannot be empty",
},
{
name: "empty path",
cheatpath: Path{
Name: "personal",
Path: "",
ReadOnly: false,
Tags: []string{"personal"},
},
wantErr: true,
errMsg: "cheatpath path cannot be empty",
},
{
name: "both empty",
cheatpath: Path{
Name: "",
Path: "",
ReadOnly: true,
Tags: nil,
},
wantErr: true,
errMsg: "cheatpath name cannot be empty",
},
{
name: "minimal valid",
cheatpath: Path{
Name: "x",
Path: "/",
},
wantErr: false,
},
{
name: "with readonly and tags",
cheatpath: Path{
Name: "community",
Path: "/usr/share/cheat",
ReadOnly: true,
Tags: []string{"community", "shared", "readonly"},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cheatpath.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("Validate() error = %v, want error containing %q", err, tt.errMsg)
}
})
}
}

View File

@@ -5,15 +5,15 @@ import (
)
// Filter filters all cheatpaths that are not named `name`
func Filter(paths []Path, name string) ([]Path, error) {
func Filter(paths []Cheatpath, name string) ([]Cheatpath, error) {
// if a path of the given name exists, return it
for _, path := range paths {
if path.Name == name {
return []Path{path}, nil
return []Cheatpath{path}, nil
}
}
// otherwise, return an error
return []Path{}, fmt.Errorf("cheatpath does not exist: %s", name)
return []Cheatpath{}, fmt.Errorf("cheatpath does not exist: %s", name)
}

View File

@@ -9,10 +9,10 @@ import (
func TestFilterSuccess(t *testing.T) {
// init cheatpaths
paths := []Path{
Path{Name: "foo"},
Path{Name: "bar"},
Path{Name: "baz"},
paths := []Cheatpath{
Cheatpath{Name: "foo"},
Cheatpath{Name: "bar"},
Cheatpath{Name: "baz"},
}
// filter the paths
@@ -39,10 +39,10 @@ func TestFilterSuccess(t *testing.T) {
func TestFilterFailure(t *testing.T) {
// init cheatpaths
paths := []Path{
Path{Name: "foo"},
Path{Name: "bar"},
Path{Name: "baz"},
paths := []Cheatpath{
Cheatpath{Name: "foo"},
Cheatpath{Name: "bar"},
Cheatpath{Name: "baz"},
}
// filter the paths

View File

@@ -4,13 +4,15 @@ import (
"fmt"
)
// Validate ensures that the Path is valid
func (c Path) Validate() error {
// Validate returns an error if the cheatpath is invalid
func (c *Cheatpath) Validate() error {
if c.Name == "" {
return fmt.Errorf("cheatpath name cannot be empty")
return fmt.Errorf("invalid cheatpath: name must be specified")
}
if c.Path == "" {
return fmt.Errorf("cheatpath path cannot be empty")
return fmt.Errorf("invalid cheatpath: path must be specified")
}
return nil
}

View File

@@ -0,0 +1,56 @@
package cheatpath
import (
"testing"
)
// TestValidateValid asserts that valid cheatpaths validate successfully
func TestValidateValid(t *testing.T) {
// initialize a valid cheatpath
cheatpath := Cheatpath{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
}
// assert that no errors are returned
if err := cheatpath.Validate(); err != nil {
t.Errorf("failed to validate valid cheatpath: %v", err)
}
}
// TestValidateMissingName asserts that paths that are missing a name fail to
// validate
func TestValidateMissingName(t *testing.T) {
// initialize a valid cheatpath
cheatpath := Cheatpath{
Path: "/foo",
ReadOnly: false,
Tags: []string{},
}
// assert that no errors are returned
if err := cheatpath.Validate(); err == nil {
t.Errorf("failed to invalidate cheatpath without name")
}
}
// TestValidateMissingPath asserts that paths that are missing a path fail to
// validate
func TestValidateMissingPath(t *testing.T) {
// initialize a valid cheatpath
cheatpath := Cheatpath{
Name: "foo",
ReadOnly: false,
Tags: []string{},
}
// assert that no errors are returned
if err := cheatpath.Validate(); err == nil {
t.Errorf("failed to invalidate cheatpath without path")
}
}

View File

@@ -4,8 +4,8 @@ import (
"fmt"
)
// Writeable returns a writeable Path
func Writeable(cheatpaths []Path) (Path, error) {
// Writeable returns a writeable Cheatpath
func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
// iterate backwards over the cheatpaths
// NB: we're going backwards because we assume that the most "local"
@@ -18,5 +18,5 @@ func Writeable(cheatpaths []Path) (Path, error) {
}
// otherwise, return an error
return Path{}, fmt.Errorf("no writeable cheatpaths found")
return Cheatpath{}, fmt.Errorf("no writeable cheatpaths found")
}

View File

@@ -9,10 +9,10 @@ import (
func TestWriteableOK(t *testing.T) {
// initialize some cheatpaths
cheatpaths := []Path{
Path{Path: "/foo", ReadOnly: true},
Path{Path: "/bar", ReadOnly: false},
Path{Path: "/baz", ReadOnly: true},
cheatpaths := []Cheatpath{
Cheatpath{Path: "/foo", ReadOnly: true},
Cheatpath{Path: "/bar", ReadOnly: false},
Cheatpath{Path: "/baz", ReadOnly: true},
}
// get the writeable cheatpath
@@ -34,10 +34,10 @@ func TestWriteableOK(t *testing.T) {
func TestWriteableNotOK(t *testing.T) {
// initialize some cheatpaths
cheatpaths := []Path{
Path{Path: "/foo", ReadOnly: true},
Path{Path: "/bar", ReadOnly: true},
Path{Path: "/baz", ReadOnly: true},
cheatpaths := []Cheatpath{
Cheatpath{Path: "/foo", ReadOnly: true},
Cheatpath{Path: "/bar", ReadOnly: true},
Cheatpath{Path: "/baz", ReadOnly: true},
}
// get the writeable cheatpath

View File

@@ -1,43 +0,0 @@
// Package completions provides dynamic shell completion functions and
// completion script generation for the cheat CLI.
package completions
import (
"sort"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/sheets"
)
// Cheatsheets provides completion for cheatsheet names.
func Cheatsheets(
_ *cobra.Command,
args []string,
_ string,
) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
conf, err := loadConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
cheatsheets, err := sheets.Load(conf.Cheatpaths)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
consolidated := sheets.Consolidate(cheatsheets)
names := make([]string, 0, len(consolidated))
for name := range consolidated {
names = append(names, name)
}
sort.Strings(names)
return names, cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -1,38 +0,0 @@
package completions
import (
"runtime"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/config"
)
// loadConfig loads the cheat configuration for use in completion functions.
// It returns an error rather than exiting, since completions should degrade
// gracefully.
func loadConfig() (config.Config, error) {
home, err := homedir.Dir()
if err != nil {
return config.Config{}, err
}
envvars := config.EnvVars()
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
return config.Config{}, err
}
confpath, err := config.Path(confpaths)
if err != nil {
return config.Config{}, err
}
conf, err := config.New(confpath, true)
if err != nil {
return config.Config{}, err
}
return conf, nil
}

View File

@@ -1,24 +0,0 @@
package completions
import (
"fmt"
"io"
"github.com/spf13/cobra"
)
// Generate writes a shell completion script to the given writer.
func Generate(cmd *cobra.Command, shell string, w io.Writer) error {
switch shell {
case "bash":
return cmd.Root().GenBashCompletionV2(w, true)
case "zsh":
return cmd.Root().GenZshCompletion(w)
case "fish":
return cmd.Root().GenFishCompletion(w, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(w)
default:
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
}
}

View File

@@ -1,25 +0,0 @@
package completions
import (
"github.com/spf13/cobra"
)
// Paths provides completion for the --path flag.
func Paths(
_ *cobra.Command,
_ []string,
_ string,
) ([]string, cobra.ShellCompDirective) {
conf, err := loadConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names := make([]string, 0, len(conf.Cheatpaths))
for _, cp := range conf.Cheatpaths {
names = append(names, cp.Name)
}
return names, cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -1,27 +0,0 @@
package completions
import (
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/sheets"
)
// Tags provides completion for the --tag flag.
func Tags(
_ *cobra.Command,
_ []string,
_ string,
) ([]string, cobra.ShellCompDirective) {
conf, err := loadConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
cheatsheets, err := sheets.Load(conf.Cheatpaths)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return sheets.Tags(cheatsheets), cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -7,7 +7,7 @@ import (
)
// Color indicates whether colorization should be applied to the output
func (c *Config) Color(forceColorize bool) bool {
func (c *Config) Color(opts map[string]interface{}) bool {
// default to the colorization specified in the configs...
colorize := c.Colorize
@@ -18,7 +18,7 @@ func (c *Config) Color(forceColorize bool) bool {
}
// ... *unless* the --colorize flag was passed
if forceColorize {
if opts["--colorize"] == true {
colorize = true
}

View File

@@ -10,11 +10,13 @@ func TestColor(t *testing.T) {
// mock a config
conf := Config{}
if conf.Color(false) {
t.Errorf("failed to respect forceColorize (false)")
opts := map[string]interface{}{"--colorize": false}
if conf.Color(opts) {
t.Errorf("failed to respect --colorize (false)")
}
if !conf.Color(true) {
t.Errorf("failed to respect forceColorize (true)")
opts = map[string]interface{}{"--colorize": true}
if !conf.Color(opts) {
t.Errorf("failed to respect --colorize (true)")
}
}

View File

@@ -1,17 +1,120 @@
// Package config implements functions pertaining to configuration management.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v2"
)
// Config encapsulates configuration parameters
type Config struct {
Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"`
Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"`
Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"`
Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"`
Style string `yaml:"style"`
Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"`
Path string
}
// New returns a new Config struct
func New(opts map[string]interface{}, confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.UnmarshalStrict(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// if a .cheat directory exists locally, append it to the cheatpaths
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
}
local := filepath.Join(cwd, ".cheat")
if _, err := os.Stat(local); err == nil {
path := cp.Cheatpath{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
for i, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path)
if err != nil {
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
}
// follow symlinks
//
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
// It's necessary because `EvalSymlinks` will error if the symlink points
// to a non-existent location on the filesystem. When unit-testing,
// however, we don't want to have dependencies on the filesystem. As such,
// `resolve` is a switch that allows us to turn off symlink resolution when
// running the config tests.
if resolve {
evaled, err := filepath.EvalSymlinks(expanded)
if err != nil {
return Config{}, fmt.Errorf(
"failed to resolve symlink: %s: %v",
expanded,
err,
)
}
expanded = evaled
}
conf.Cheatpaths[i].Path = expanded
}
// if an editor was not provided in the configs, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
// if a chroma style was not provided, set a default
if conf.Style == "" {
conf.Style = "bw"
}
// if a chroma formatter was not provided, set a default
if conf.Formatter == "" {
conf.Formatter = "terminal"
}
// load the pager
conf.Pager = strings.TrimSpace(conf.Pager)
return conf, nil
}

View File

@@ -1,148 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/cheat/cheat/mocks"
)
// TestConfigYAMLErrors tests YAML parsing errors
func TestConfigYAMLErrors(t *testing.T) {
// Create a temporary file with invalid YAML
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
invalidYAML := filepath.Join(tempDir, "invalid.yml")
err = os.WriteFile(invalidYAML, []byte("cheatpaths: [{unclosed\n"), 0644)
if err != nil {
t.Fatalf("failed to write invalid yaml: %v", err)
}
// Attempt to load invalid YAML
_, err = New(invalidYAML, false)
if err == nil {
t.Error("expected error for invalid YAML, got nil")
}
}
// TestConfigDefaults tests default values
func TestConfigDefaults(t *testing.T) {
// Load empty config
conf, err := New(mocks.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
// Check defaults
if conf.Style != "bw" {
t.Errorf("expected default style 'bw', got %s", conf.Style)
}
if conf.Formatter != "terminal" {
t.Errorf("expected default formatter 'terminal', got %s", conf.Formatter)
}
}
// TestConfigSymlinkResolution tests symlink resolution
func TestConfigSymlinkResolution(t *testing.T) {
// Create temp directory structure
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
tempDir, err = filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
}
// Create target directory
targetDir := filepath.Join(tempDir, "target")
err = os.Mkdir(targetDir, 0755)
if err != nil {
t.Fatalf("failed to create target dir: %v", err)
}
// Create symlink
linkPath := filepath.Join(tempDir, "link")
err = os.Symlink(targetDir, linkPath)
if err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// Create config with symlink path
configContent := `---
editor: vim
cheatpaths:
- name: test
path: ` + linkPath + `
readonly: true
`
configFile := filepath.Join(tempDir, "config.yml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatalf("failed to write config: %v", err)
}
// Load config with symlink resolution
conf, err := New(configFile, true)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
// Verify symlink was resolved
if len(conf.Cheatpaths) == 0 {
t.Fatal("expected at least one cheatpath, got none")
}
if conf.Cheatpaths[0].Path != targetDir {
t.Errorf("expected symlink to be resolved to %s, got %s", targetDir, conf.Cheatpaths[0].Path)
}
}
// TestConfigBrokenSymlink tests broken symlink handling
func TestConfigBrokenSymlink(t *testing.T) {
// Create temp directory
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create broken symlink
linkPath := filepath.Join(tempDir, "broken-link")
err = os.Symlink("/nonexistent/path", linkPath)
if err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// Create config with broken symlink
configContent := `---
editor: vim
cheatpaths:
- name: test
path: ` + linkPath + `
readonly: true
`
configFile := filepath.Join(tempDir, "config.yml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatalf("failed to write config: %v", err)
}
// Load config with symlink resolution should skip the broken cheatpath
// (warn to stderr) rather than hard-error
conf, err := New(configFile, true)
if err != nil {
t.Errorf("expected no error for broken symlink (should skip), got: %v", err)
}
if len(conf.Cheatpaths) != 0 {
t.Errorf("expected broken cheatpath to be filtered out, got %d cheatpaths", len(conf.Cheatpaths))
}
}

View File

@@ -1,67 +0,0 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// FuzzFindLocalCheatpath exercises findLocalCheatpath with randomised
// directory depths and .cheat placements. For each fuzz input it builds a
// temporary directory hierarchy, places a single .cheat directory at a
// computed level, and asserts that the function always returns it.
func FuzzFindLocalCheatpath(f *testing.F) {
// Seed corpus: (totalDepth, cheatPlacement)
f.Add(uint8(1), uint8(0)) // depth 1, .cheat at root
f.Add(uint8(3), uint8(0)) // depth 3, .cheat at root
f.Add(uint8(5), uint8(3)) // depth 5, .cheat at level 3
f.Add(uint8(1), uint8(1)) // depth 1, .cheat at same level as search dir
f.Add(uint8(10), uint8(5)) // deep hierarchy
f.Fuzz(func(t *testing.T, totalDepth uint8, cheatPlacement uint8) {
// Clamp to reasonable values to keep I/O bounded
depth := int(totalDepth%15) + 1 // 1..15
cheatAt := int(cheatPlacement) % (depth + 1) // 0..depth (0 = tempDir itself)
tempDir := t.TempDir()
// Build chain: tempDir/d0/d1/…/d{depth-1}
dirs := make([]string, 0, depth+1)
dirs = append(dirs, tempDir)
current := tempDir
for i := 0; i < depth; i++ {
current = filepath.Join(current, fmt.Sprintf("d%d", i))
if err := os.Mkdir(current, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
dirs = append(dirs, current)
}
// Place .cheat at dirs[cheatAt]
cheatDir := filepath.Join(dirs[cheatAt], ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("mkdir .cheat: %v", err)
}
// Search from the deepest directory
result := findLocalCheatpath(current)
// Invariant 1: must find the .cheat we placed
if result != cheatDir {
t.Errorf("depth=%d cheatAt=%d: expected %s, got %s",
depth, cheatAt, cheatDir, result)
}
// Invariant 2: result must end with /.cheat
if !strings.HasSuffix(result, string(filepath.Separator)+".cheat") {
t.Errorf("result %q does not end with /.cheat", result)
}
// Invariant 3: result must be under tempDir
if !strings.HasPrefix(result, tempDir) {
t.Errorf("result %q is not under tempDir %s", result, tempDir)
}
})
}

View File

@@ -4,289 +4,20 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/mocks"
"github.com/cheat/cheat/internal/mock"
)
// TestFindLocalCheatpathInCurrentDir tests that .cheat in the given dir is found
func TestFindLocalCheatpathInCurrentDir(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cheatDir := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
result := findLocalCheatpath(tempDir)
if result != cheatDir {
t.Errorf("expected %s, got %s", cheatDir, result)
}
}
// TestFindLocalCheatpathInParent tests walking up to a parent directory
func TestFindLocalCheatpathInParent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cheatDir := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
result := findLocalCheatpath(subDir)
if result != cheatDir {
t.Errorf("expected %s, got %s", cheatDir, result)
}
}
// TestFindLocalCheatpathInGrandparent tests walking up multiple levels
func TestFindLocalCheatpathInGrandparent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cheatDir := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
deepDir := filepath.Join(tempDir, "a", "b", "c")
if err := os.MkdirAll(deepDir, 0755); err != nil {
t.Fatalf("failed to create deep dir: %v", err)
}
result := findLocalCheatpath(deepDir)
if result != cheatDir {
t.Errorf("expected %s, got %s", cheatDir, result)
}
}
// TestFindLocalCheatpathNearestWins tests that the closest .cheat is returned
func TestFindLocalCheatpathNearestWins(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create .cheat at root level
if err := os.Mkdir(filepath.Join(tempDir, ".cheat"), 0755); err != nil {
t.Fatalf("failed to create root .cheat dir: %v", err)
}
// Create sub/.cheat (the nearer one)
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
nearCheatDir := filepath.Join(subDir, ".cheat")
if err := os.Mkdir(nearCheatDir, 0755); err != nil {
t.Fatalf("failed to create sub .cheat dir: %v", err)
}
// Search from sub/deep/
deepDir := filepath.Join(subDir, "deep")
if err := os.Mkdir(deepDir, 0755); err != nil {
t.Fatalf("failed to create deep dir: %v", err)
}
result := findLocalCheatpath(deepDir)
if result != nearCheatDir {
t.Errorf("expected nearest %s, got %s", nearCheatDir, result)
}
}
// TestFindLocalCheatpathNotFound tests that empty string is returned when no .cheat exists
func TestFindLocalCheatpathNotFound(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
result := findLocalCheatpath(tempDir)
if result != "" {
t.Errorf("expected empty string, got %s", result)
}
}
// TestFindLocalCheatpathSkipsFile tests that a file named .cheat is not matched
func TestFindLocalCheatpathSkipsFile(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create .cheat as a file, not a directory
cheatFile := filepath.Join(tempDir, ".cheat")
if err := os.WriteFile(cheatFile, []byte("not a directory"), 0644); err != nil {
t.Fatalf("failed to create .cheat file: %v", err)
}
result := findLocalCheatpath(tempDir)
if result != "" {
t.Errorf("expected empty string for .cheat file, got %s", result)
}
}
// TestFindLocalCheatpathSymlink tests that a .cheat symlink to a directory is found
func TestFindLocalCheatpathSymlink(t *testing.T) {
tempDir := t.TempDir()
// Create the real directory
realDir := filepath.Join(tempDir, "real-cheat")
if err := os.Mkdir(realDir, 0755); err != nil {
t.Fatalf("failed to create real dir: %v", err)
}
// Symlink .cheat -> real-cheat
cheatLink := filepath.Join(tempDir, ".cheat")
if err := os.Symlink(realDir, cheatLink); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
result := findLocalCheatpath(tempDir)
if result != cheatLink {
t.Errorf("expected %s, got %s", cheatLink, result)
}
}
// TestFindLocalCheatpathSymlinkInAncestor tests discovery through a symlinked
// ancestor directory. When the cwd is reached via a symlink, filepath.Dir
// walks the symlinked path (not the real path), so .cheat must be findable
// through that chain.
func TestFindLocalCheatpathSymlinkInAncestor(t *testing.T) {
tempDir := t.TempDir()
// Create real/project/.cheat
realProject := filepath.Join(tempDir, "real", "project")
if err := os.MkdirAll(realProject, 0755); err != nil {
t.Fatalf("failed to create real project dir: %v", err)
}
if err := os.Mkdir(filepath.Join(realProject, ".cheat"), 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Create symlink: linked -> real/project
linkedProject := filepath.Join(tempDir, "linked")
if err := os.Symlink(realProject, linkedProject); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// Create sub inside the symlinked path
subDir := filepath.Join(linkedProject, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
// Search from linked/sub — should find linked/.cheat
// (os.Stat follows symlinks, so linked/.cheat resolves to real/project/.cheat)
result := findLocalCheatpath(subDir)
expected := filepath.Join(linkedProject, ".cheat")
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
// TestFindLocalCheatpathPermissionDenied tests that unreadable ancestor
// directories are skipped and the walk continues upward.
func TestFindLocalCheatpathPermissionDenied(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permissions do not apply on Windows")
}
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
tempDir := t.TempDir()
// Resolve symlinks (macOS /var -> /private/var)
tempDir, err := filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve symlinks: %v", err)
}
// Create tempDir/.cheat (the target we want found)
cheatDir := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Create tempDir/restricted/ with its own .cheat and sub/
restricted := filepath.Join(tempDir, "restricted")
if err := os.Mkdir(restricted, 0755); err != nil {
t.Fatalf("failed to create restricted dir: %v", err)
}
if err := os.Mkdir(filepath.Join(restricted, ".cheat"), 0755); err != nil {
t.Fatalf("failed to create restricted .cheat dir: %v", err)
}
subDir := filepath.Join(restricted, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
// Make restricted/ unreadable — blocks stat of children
if err := os.Chmod(restricted, 0000); err != nil {
t.Fatalf("failed to chmod: %v", err)
}
t.Cleanup(func() { os.Chmod(restricted, 0755) })
// Walk from restricted/sub: stat("restricted/sub/.cheat") fails (EACCES),
// stat("restricted/.cheat") fails (EACCES), walk continues to tempDir/.cheat
result := findLocalCheatpath(subDir)
if result != cheatDir {
t.Errorf("expected %s (walked past restricted dir), got %s", cheatDir, result)
}
}
// TestConfig asserts that the configs are loaded correctly
func TestConfigSuccessful(t *testing.T) {
// Chdir into a temp directory so no ancestor .cheat directory can
// leak into the cheatpaths (findLocalCheatpath walks the full
// ancestor chain).
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
// clear env vars so they don't override the config file value
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
// initialize a config
conf, err := New(mocks.Path("conf/conf.yml"), false)
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
if err != nil {
t.Errorf("failed to parse config file: %v", err)
}
@@ -306,18 +37,18 @@ func TestConfigSuccessful(t *testing.T) {
}
// assert that the cheatpaths are correct
want := []cheatpath.Path{
cheatpath.Path{
want := []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Path: filepath.Join(home, ".dotfiles", "cheat", "community"),
ReadOnly: true,
Tags: []string{"community"},
},
cheatpath.Path{
cheatpath.Cheatpath{
Path: filepath.Join(home, ".dotfiles", "cheat", "work"),
ReadOnly: false,
Tags: []string{"work"},
},
cheatpath.Path{
cheatpath.Cheatpath{
Path: filepath.Join(home, ".dotfiles", "cheat", "personal"),
ReadOnly: false,
Tags: []string{"personal"},
@@ -338,84 +69,43 @@ func TestConfigSuccessful(t *testing.T) {
func TestConfigFailure(t *testing.T) {
// attempt to read a non-existent config file
_, err := New("/does-not-exit", false)
_, err := New(map[string]interface{}{}, "/does-not-exit", false)
if err == nil {
t.Errorf("failed to error on unreadable config")
}
}
// TestEditorEnvOverride asserts that $VISUAL and $EDITOR override the
// config file value at runtime (regression test for #589)
func TestEditorEnvOverride(t *testing.T) {
// save and clear the environment variables
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
// TestEmptyEditor asserts that envvars are respected if an editor is not
// specified in the configs
func TestEmptyEditor(t *testing.T) {
// with no env vars, the config file value should be used
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
conf, err := New(mocks.Path("conf/conf.yml"), false)
// clear the environment variables
os.Setenv("VISUAL", "")
os.Setenv("EDITOR", "")
// initialize a config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "vim" {
t.Errorf("expected config file editor: want: vim, got: %s", conf.Editor)
t.Errorf("failed to initialize test: %v", err)
}
// $EDITOR should override the config file value
os.Setenv("EDITOR", "nano")
conf, err = New(mocks.Path("conf/conf.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "nano" {
t.Errorf("$EDITOR should override config: want: nano, got: %s", conf.Editor)
}
// $VISUAL should override both $EDITOR and the config file value
os.Setenv("VISUAL", "emacs")
conf, err = New(mocks.Path("conf/conf.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "emacs" {
t.Errorf("$VISUAL should override all: want: emacs, got: %s", conf.Editor)
}
}
// TestEditorEnvFallback asserts that env vars are used as fallback when
// no editor is specified in the config file
func TestEditorEnvFallback(t *testing.T) {
// save and clear the environment variables
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
// set $EDITOR and assert it's used when config has no editor
os.Unsetenv("VISUAL")
// set editor, and assert that it is respected
os.Setenv("EDITOR", "foo")
conf, err := New(mocks.Path("conf/empty.yml"), false)
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
t.Errorf("failed to init configs: %v", err)
}
if conf.Editor != "foo" {
t.Errorf("failed to respect $EDITOR: want: foo, got: %s", conf.Editor)
t.Errorf("failed to respect editor: want: foo, got: %s", conf.Editor)
}
// set $VISUAL and assert it takes precedence over $EDITOR
// set visual, and assert that it overrides editor
os.Setenv("VISUAL", "bar")
conf, err = New(mocks.Path("conf/empty.yml"), false)
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
t.Errorf("failed to init configs: %v", err)
}
if conf.Editor != "bar" {
t.Errorf("failed to respect $VISUAL: want: bar, got: %s", conf.Editor)
t.Errorf("failed to respect editor: want: bar, got: %s", conf.Editor)
}
}

View File

@@ -1,95 +0,0 @@
package config
import (
"os"
"runtime"
"testing"
)
// TestEditor tests the Editor function
func TestEditor(t *testing.T) {
// Save original env vars
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
t.Run("windows default", func(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("skipping windows test on non-windows platform")
}
// Clear env vars
os.Setenv("VISUAL", "")
os.Setenv("EDITOR", "")
editor, err := Editor()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if editor != "notepad" {
t.Errorf("expected 'notepad' on windows, got %s", editor)
}
})
t.Run("VISUAL takes precedence", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping non-windows test on windows platform")
}
os.Setenv("VISUAL", "emacs")
os.Setenv("EDITOR", "nano")
editor, err := Editor()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if editor != "emacs" {
t.Errorf("expected VISUAL to take precedence, got %s", editor)
}
})
t.Run("EDITOR when no VISUAL", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping non-windows test on windows platform")
}
os.Setenv("VISUAL", "")
os.Setenv("EDITOR", "vim")
editor, err := Editor()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if editor != "vim" {
t.Errorf("expected EDITOR value, got %s", editor)
}
})
t.Run("no editor found error", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping non-windows test on windows platform")
}
// Clear all environment variables
os.Setenv("VISUAL", "")
os.Setenv("EDITOR", "")
// Create a custom PATH that doesn't include common editors
oldPath := os.Getenv("PATH")
defer os.Setenv("PATH", oldPath)
// Set a very limited PATH that won't have editors
os.Setenv("PATH", "/nonexistent")
editor, err := Editor()
// If we found an editor, it's likely in the system
// This test might not always produce an error on systems with editors
if editor == "" && err == nil {
t.Error("expected error when no editor found")
}
})
}

View File

@@ -1,20 +0,0 @@
package config
import (
"os"
"runtime"
"strings"
)
// EnvVars reads environment variables into a map of strings.
func EnvVars() map[string]string {
envvars := map[string]string{}
for _, e := range os.Environ() {
pair := strings.SplitN(e, "=", 2)
if runtime.GOOS == "windows" {
pair[0] = strings.ToUpper(pair[0])
}
envvars[pair[0]] = pair[1]
}
return envvars
}

View File

@@ -2,8 +2,6 @@ package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -37,87 +35,3 @@ func TestInit(t *testing.T) {
t.Errorf("failed to write configs: want: %s, got: %s", conf, got)
}
}
// TestInitCreateDirectory tests that Init creates the directory if it doesn't exist
func TestInitCreateDirectory(t *testing.T) {
// Create a temp directory
tempDir, err := os.MkdirTemp("", "cheat-init-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Path to a config file in a non-existent subdirectory
confPath := filepath.Join(tempDir, "subdir", "conf.yml")
// Initialize the config file
conf := "test config"
if err = Init(confPath, conf); err != nil {
t.Errorf("failed to init config file: %v", err)
}
// Verify the directory was created
if _, err := os.Stat(filepath.Dir(confPath)); os.IsNotExist(err) {
t.Error("Init did not create the directory")
}
// Verify the file was created with correct content
bytes, err := os.ReadFile(confPath)
if err != nil {
t.Errorf("failed to read config file: %v", err)
}
if string(bytes) != conf {
t.Errorf("config content mismatch: got %q, want %q", string(bytes), conf)
}
}
// TestInitWriteError tests error handling when file write fails
func TestInitWriteError(t *testing.T) {
// Skip this test if running as root (can write anywhere)
if runtime.GOOS != "windows" && os.Getuid() == 0 {
t.Skip("Cannot test write errors as root")
}
// Use a platform-appropriate invalid path
invalidPath := "/dev/null/impossible/path/conf.yml"
if runtime.GOOS == "windows" {
invalidPath = `NUL\impossible\path\conf.yml`
}
// Try to write to a read-only directory
err := Init(invalidPath, "test")
if err == nil {
t.Error("expected error when writing to invalid path, got nil")
}
}
// TestInitExistingFile tests that Init overwrites existing files
func TestInitExistingFile(t *testing.T) {
// Create a temp file
tempFile, err := os.CreateTemp("", "cheat-init-existing-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tempFile.Name())
// Write initial content
initialContent := "initial content"
if err := os.WriteFile(tempFile.Name(), []byte(initialContent), 0644); err != nil {
t.Fatalf("failed to write initial content: %v", err)
}
// Initialize with new content
newContent := "new config content"
if err = Init(tempFile.Name(), newContent); err != nil {
t.Errorf("failed to init over existing file: %v", err)
}
// Verify the file was overwritten
bytes, err := os.ReadFile(tempFile.Name())
if err != nil {
t.Errorf("failed to read config file: %v", err)
}
if string(bytes) != newContent {
t.Errorf("config not overwritten: got %q, want %q", string(bytes), newContent)
}
}

View File

@@ -1,147 +0,0 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
)
// New returns a new Config struct
func New(confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.Unmarshal(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// if a .cheat directory exists in the current directory or any ancestor,
// append it to the cheatpaths
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
}
if local := findLocalCheatpath(cwd); local != "" {
path := cp.Path{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
var validPaths []cp.Path
for _, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path)
if err != nil {
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
}
// follow symlinks
//
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
// It's necessary because `EvalSymlinks` will error if the symlink points
// to a non-existent location on the filesystem. When unit-testing,
// however, we don't want to have dependencies on the filesystem. As such,
// `resolve` is a switch that allows us to turn off symlink resolution when
// running the config tests.
if resolve {
evaled, err := filepath.EvalSymlinks(expanded)
if err != nil {
// if the path simply doesn't exist, warn and skip it
if os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"WARNING: cheatpath '%s' does not exist, skipping\n",
expanded,
)
continue
}
return Config{}, fmt.Errorf(
"failed to resolve symlink: %s: %v",
expanded,
err,
)
}
expanded = evaled
}
cheatpath.Path = expanded
validPaths = append(validPaths, cheatpath)
}
conf.Cheatpaths = validPaths
// determine the editor: env vars override the config file value,
// following standard Unix convention (see #589)
if v := os.Getenv("VISUAL"); v != "" {
conf.Editor = v
} else if v := os.Getenv("EDITOR"); v != "" {
conf.Editor = v
} else {
conf.Editor = strings.TrimSpace(conf.Editor)
}
// if an editor was still not determined, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
// if a chroma style was not provided, set a default
if conf.Style == "" {
conf.Style = "bw"
}
// if a chroma formatter was not provided, set a default
if conf.Formatter == "" {
conf.Formatter = "terminal"
}
// load the pager
conf.Pager = strings.TrimSpace(conf.Pager)
return conf, nil
}
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
// It returns the path to the first .cheat directory found, or an empty string
// if none exists. This mirrors the discovery pattern used by git for .git
// directories.
func findLocalCheatpath(dir string) string {
for {
candidate := filepath.Join(dir, ".cheat")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

View File

@@ -1,135 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestNewTrimsWhitespace(t *testing.T) {
// clear env vars so they don't override the config file value
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
// Create a temporary config file with whitespace in editor and pager
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
configContent := `---
editor: " vim -c 'set number' "
pager: " less -R "
style: monokai
formatter: terminal
cheatpaths:
- name: personal
path: ~/cheat
tags: []
readonly: false
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
// Load the config
conf, err := New(configPath, false)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
// Verify editor is trimmed
expectedEditor := "vim -c 'set number'"
if conf.Editor != expectedEditor {
t.Errorf("editor not properly trimmed: got %q, want %q", conf.Editor, expectedEditor)
}
// Verify pager is trimmed
expectedPager := "less -R"
if conf.Pager != expectedPager {
t.Errorf("pager not properly trimmed: got %q, want %q", conf.Pager, expectedPager)
}
}
func TestNewEmptyEditorFallback(t *testing.T) {
// Skip if required environment variables would interfere
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
// Create a config with whitespace-only editor
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
configContent := `---
editor: " "
pager: less
style: monokai
formatter: terminal
cheatpaths:
- name: personal
path: ~/cheat
tags: []
readonly: false
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
// Load the config
conf, err := New(configPath, false)
if err != nil {
// It's OK if this fails due to no editor being found
// The important thing is it doesn't panic
return
}
// If it succeeded, editor should not be empty (fallback was used)
if conf.Editor == "" {
t.Error("editor should not be empty after fallback")
}
}
func TestNewWhitespaceOnlyPager(t *testing.T) {
// Create a config with whitespace-only pager
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
configContent := `---
editor: vim
pager: " "
style: monokai
formatter: terminal
cheatpaths:
- name: personal
path: ~/cheat
tags: []
readonly: false
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
// Load the config
conf, err := New(configPath, false)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
// Pager should be empty after trimming
if conf.Pager != "" {
t.Errorf("pager should be empty after trimming whitespace: got %q", conf.Pager)
}
}

View File

@@ -22,7 +22,7 @@ func Pager() string {
// Otherwise, search for `pager`, `less`, and `more` on the `$PATH`. If
// none are found, return an empty pager.
for _, pager := range []string{"pager", "less", "more"} {
if path, err := exec.LookPath(pager); err == nil {
if path, err := exec.LookPath(pager); err != nil {
return path
}
}

View File

@@ -1,82 +0,0 @@
package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestPager tests the Pager function
func TestPager(t *testing.T) {
// Save original env var
oldPager := os.Getenv("PAGER")
defer os.Setenv("PAGER", oldPager)
t.Run("windows default", func(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("skipping windows test on non-windows platform")
}
os.Setenv("PAGER", "")
pager := Pager()
if pager != "more" {
t.Errorf("expected 'more' on windows, got %s", pager)
}
})
t.Run("PAGER env var", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping non-windows test on windows platform")
}
os.Setenv("PAGER", "bat")
pager := Pager()
if pager != "bat" {
t.Errorf("expected PAGER env var value, got %s", pager)
}
})
t.Run("fallback to system pager", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping non-windows test on windows platform")
}
os.Setenv("PAGER", "")
pager := Pager()
if pager == "" {
return // no pager found is acceptable
}
// Should find one of the known fallback pagers
validPagers := map[string]bool{
"pager": true,
"less": true,
"more": true,
}
base := filepath.Base(pager)
if !validPagers[base] {
t.Errorf("unexpected pager value: %s (base: %s)", pager, base)
}
})
t.Run("no pager available", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping non-windows test on windows platform")
}
os.Setenv("PAGER", "")
// Save and modify PATH to ensure no pagers are found
oldPath := os.Getenv("PATH")
defer os.Setenv("PATH", oldPath)
os.Setenv("PATH", "/nonexistent")
pager := Pager()
if pager != "" {
t.Errorf("expected empty string when no pager found, got %s", pager)
}
})
}

View File

@@ -1,9 +1,7 @@
package config
import (
"path/filepath"
"reflect"
"runtime"
"testing"
"github.com/davecgh/go-spew/spew"
@@ -12,9 +10,6 @@ import (
// TestValidatePathsNix asserts that the proper config paths are returned on
// *nix platforms
func TestValidatePathsNix(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("filepath.Join uses backslashes on Windows")
}
// mock the user's home directory
home := "/home/foo"
@@ -62,9 +57,6 @@ func TestValidatePathsNix(t *testing.T) {
// TestValidatePathsNixNoXDG asserts that the proper config paths are returned
// on *nix platforms when `XDG_CONFIG_HOME is not set
func TestValidatePathsNixNoXDG(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("filepath.Join uses backslashes on Windows")
}
// mock the user's home directory
home := "/home/foo"
@@ -114,8 +106,8 @@ func TestValidatePathsWindows(t *testing.T) {
// mock some envvars
envvars := map[string]string{
"APPDATA": filepath.Join("C:", "apps"),
"PROGRAMDATA": filepath.Join("C:", "programs"),
"APPDATA": "/apps",
"PROGRAMDATA": "/programs",
}
// get the paths for the platform
@@ -126,8 +118,8 @@ func TestValidatePathsWindows(t *testing.T) {
// specify the expected output
want := []string{
filepath.Join("C:", "apps", "cheat", "conf.yml"),
filepath.Join("C:", "programs", "cheat", "conf.yml"),
"/apps/cheat/conf.yml",
"/programs/cheat/conf.yml",
}
// assert that output matches expectations

View File

@@ -14,8 +14,8 @@ func TestValidateCorrect(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Name: "foo",
Path: "/foo",
ReadOnly: false,
@@ -38,8 +38,8 @@ func TestInvalidateMissingEditor(t *testing.T) {
conf := Config{
Colorize: true,
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Name: "foo",
Path: "/foo",
ReadOnly: false,
@@ -71,28 +71,19 @@ func TestInvalidateMissingCheatpaths(t *testing.T) {
}
}
// TestInvalidateInvalidFormatter asserts that configs which contain invalid
// TestMissingInvalidFormatters asserts that configs which contain invalid
// formatters are invalidated
func TestInvalidateInvalidFormatter(t *testing.T) {
func TestMissingInvalidFormatters(t *testing.T) {
// mock a config with a valid editor and cheatpaths but invalid formatter
// mock a config
conf := Config{
Colorize: true,
Editor: "vim",
Formatter: "html",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
},
Colorize: true,
Editor: "vim",
}
// assert that the config is invalidated due to the formatter
// assert that no errors are returned
if err := conf.Validate(); err == nil {
t.Errorf("failed to invalidate config with invalid formatter")
t.Errorf("failed to invalidate config without formatter")
}
}
@@ -105,14 +96,14 @@ func TestInvalidateDuplicateCheatpathNames(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
cheatpath.Path{
cheatpath.Cheatpath{
Name: "foo",
Path: "/bar",
ReadOnly: false,
@@ -136,14 +127,14 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
cheatpath.Path{
cheatpath.Cheatpath{
Name: "bar",
Path: "/foo",
ReadOnly: false,
@@ -157,28 +148,3 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
t.Errorf("failed to invalidate config with cheatpaths with duplicate paths")
}
}
// TestInvalidateInvalidCheatpath asserts that configs containing a cheatpath
// with an empty name are invalidated
func TestInvalidateInvalidCheatpath(t *testing.T) {
// mock a config with a cheatpath that has an empty name
conf := Config{
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
},
}
// assert that an error is returned
if err := conf.Validate(); err == nil {
t.Errorf("failed to invalidate config with invalid cheatpath (empty name)")
}
}

View File

@@ -1,14 +1,15 @@
// Package display implement functions pertaining to writing formatted
// cheatsheet content to stdout, or alternatively the system pager.
package display
import "fmt"
import (
"fmt"
// Faint returns a faintly-colored string that's used to de-prioritize text
// written to stdout
func Faint(str string, colorize bool) string {
"github.com/cheat/cheat/internal/config"
)
// Faint returns an faint string
func Faint(str string, conf config.Config) string {
// make `str` faint only if colorization has been requested
if colorize {
if conf.Colorize {
return fmt.Sprintf("\033[2m%s\033[0m", str)
}

View File

@@ -1,20 +1,26 @@
package display
import "testing"
import (
"testing"
"github.com/cheat/cheat/internal/config"
)
// TestFaint asserts that Faint applies faint formatting
func TestFaint(t *testing.T) {
// case: apply colorization
conf := config.Config{Colorize: true}
want := "\033[2mfoo\033[0m"
got := Faint("foo", true)
got := Faint("foo", conf)
if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got)
}
// case: do not apply colorization
conf.Colorize = false
want = "foo"
got = Faint("foo", false)
got = Faint("foo", conf)
if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got)
}

View File

@@ -10,13 +10,3 @@ func TestIndent(t *testing.T) {
t.Errorf("failed to indent: want: %s, got: %s", want, got)
}
}
// TestIndentTrimsWhitespace asserts that Indent trims leading and trailing
// whitespace before indenting
func TestIndentTrimsWhitespace(t *testing.T) {
got := Indent(" foo\nbar\nbaz \n")
want := "\tfoo\n\tbar\n\tbaz\n"
if got != want {
t.Errorf("failed to trim and indent: want: %q, got: %q", want, got)
}
}

View File

@@ -19,12 +19,7 @@ func Write(out string, conf config.Config) {
}
// otherwise, pipe output through the pager
writeToPager(out, conf)
}
// writeToPager writes output through a pager command
func writeToPager(out string, conf config.Config) {
parts := strings.Fields(conf.Pager)
parts := strings.Split(conf.Pager, " ")
pager := parts[0]
args := parts[1:]

View File

@@ -1,136 +0,0 @@
package display
import (
"bytes"
"io"
"os"
"os/exec"
"strings"
"testing"
"github.com/cheat/cheat/internal/config"
)
// TestWriteToPager tests the writeToPager function
func TestWriteToPager(t *testing.T) {
// Skip these tests in CI/CD environments where interactive commands might not work
if os.Getenv("CI") != "" {
t.Skip("Skipping pager tests in CI environment")
}
// Note: We can't easily test os.Exit calls, so we focus on testing writeToPager
// which contains the core logic
t.Run("successful pager execution", func(t *testing.T) {
// Save original stdout
oldStdout := os.Stdout
defer func() {
os.Stdout = oldStdout
}()
// Create pipe for capturing output
r, w, _ := os.Pipe()
os.Stdout = w
// Use 'cat' as a simple pager that just outputs input
conf := config.Config{
Pager: "cat",
}
// This will call os.Exit on error, so we need to be careful
// We're using 'cat' which should always succeed
input := "Test output\n"
// Run in a goroutine to avoid blocking
done := make(chan bool)
go func() {
writeToPager(input, conf)
done <- true
}()
// Wait for completion or timeout
select {
case <-done:
// Success
}
// Close write end and read output
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
// Verify output
if buf.String() != input {
t.Errorf("expected output %q, got %q", input, buf.String())
}
})
t.Run("pager with arguments", func(t *testing.T) {
// Save original stdout
oldStdout := os.Stdout
defer func() {
os.Stdout = oldStdout
}()
// Create pipe for capturing output
r, w, _ := os.Pipe()
os.Stdout = w
// Use 'cat' with '-A' flag (shows non-printing characters)
conf := config.Config{
Pager: "cat -A",
}
input := "Test\toutput\n"
// Run in a goroutine
done := make(chan bool)
go func() {
writeToPager(input, conf)
done <- true
}()
// Wait for completion
select {
case <-done:
// Success
}
// Close write end and read output
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
// cat -A shows tabs as ^I and line endings as $
expected := "Test^Ioutput$\n"
if buf.String() != expected {
t.Errorf("expected output %q, got %q", expected, buf.String())
}
})
}
// TestWriteToPagerError tests error handling in writeToPager
func TestWriteToPagerError(t *testing.T) {
if os.Getenv("TEST_PAGER_ERROR_SUBPROCESS") == "1" {
// This is the subprocess - run the actual test
conf := config.Config{Pager: "/nonexistent/command"}
writeToPager("test", conf)
return
}
// Run test in subprocess to handle os.Exit
cmd := exec.Command(os.Args[0], "-test.run=^TestWriteToPagerError$")
cmd.Env = append(os.Environ(), "TEST_PAGER_ERROR_SUBPROCESS=1")
output, err := cmd.CombinedOutput()
// Should exit with error
if err == nil {
t.Error("expected process to exit with error")
}
// Should contain error message
if !strings.Contains(string(output), "failed to write to pager") {
t.Errorf("expected error message about pager failure, got %q", string(output))
}
}

View File

@@ -1,8 +1,7 @@
// Package installer implements functions that provide a first-time
// installation wizard.
package installer
import (
"bufio"
"fmt"
"os"
"strings"
@@ -11,34 +10,20 @@ import (
// Prompt prompts the user for a answer
func Prompt(prompt string, def bool) (bool, error) {
// initialize a line reader
reader := bufio.NewReader(os.Stdin)
// display the prompt
fmt.Printf("%s: ", prompt)
// read one byte at a time until newline to avoid buffering past the
// end of the current line, which would consume input intended for
// subsequent Prompt calls on the same stdin
var line []byte
buf := make([]byte, 1)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
if buf[0] == '\n' {
break
}
if buf[0] != '\r' {
line = append(line, buf[0])
}
}
if err != nil {
if len(line) > 0 {
break
}
return false, fmt.Errorf("failed to prompt: %v", err)
}
// read the answer
ans, err := reader.ReadString('\n')
if err != nil {
return false, fmt.Errorf("failed to parse input: %v", err)
}
// normalize the answer
ans := strings.ToLower(strings.TrimSpace(string(line)))
ans = strings.ToLower(strings.TrimSpace(ans))
// return the appropriate response
switch ans {

View File

@@ -1,159 +0,0 @@
package installer
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func TestPrompt(t *testing.T) {
// Save original stdin/stdout
oldStdin := os.Stdin
oldStdout := os.Stdout
defer func() {
os.Stdin = oldStdin
os.Stdout = oldStdout
}()
tests := []struct {
name string
prompt string
input string
defaultVal bool
want bool
wantErr bool
wantPrompt string
}{
{
name: "answer yes",
prompt: "Continue?",
input: "y\n",
defaultVal: false,
want: true,
wantPrompt: "Continue?: ",
},
{
name: "answer yes with uppercase",
prompt: "Continue?",
input: "Y\n",
defaultVal: false,
want: true,
wantPrompt: "Continue?: ",
},
{
name: "answer yes with spaces",
prompt: "Continue?",
input: " y \n",
defaultVal: false,
want: true,
wantPrompt: "Continue?: ",
},
{
name: "answer no",
prompt: "Continue?",
input: "n\n",
defaultVal: true,
want: false,
wantPrompt: "Continue?: ",
},
{
name: "answer no with any text",
prompt: "Continue?",
input: "anything\n",
defaultVal: true,
want: false,
wantPrompt: "Continue?: ",
},
{
name: "empty answer uses default true",
prompt: "Continue?",
input: "\n",
defaultVal: true,
want: true,
wantPrompt: "Continue?: ",
},
{
name: "empty answer uses default false",
prompt: "Continue?",
input: "\n",
defaultVal: false,
want: false,
wantPrompt: "Continue?: ",
},
{
name: "whitespace answer uses default",
prompt: "Continue?",
input: " \n",
defaultVal: true,
want: true,
wantPrompt: "Continue?: ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a pipe for stdin
r, w, _ := os.Pipe()
os.Stdin = r
// Create a pipe for stdout to capture the prompt
rOut, wOut, _ := os.Pipe()
os.Stdout = wOut
// Write input to stdin
go func() {
defer w.Close()
io.WriteString(w, tt.input)
}()
// Call the function
got, err := Prompt(tt.prompt, tt.defaultVal)
// Close stdout write end and read the prompt
wOut.Close()
var buf bytes.Buffer
io.Copy(&buf, rOut)
// Check error
if (err != nil) != tt.wantErr {
t.Errorf("Prompt() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Check result
if got != tt.want {
t.Errorf("Prompt() = %v, want %v", got, tt.want)
}
// Check that prompt was displayed correctly
if buf.String() != tt.wantPrompt {
t.Errorf("Prompt display = %q, want %q", buf.String(), tt.wantPrompt)
}
})
}
}
func TestPromptError(t *testing.T) {
// Save original stdin
oldStdin := os.Stdin
defer func() {
os.Stdin = oldStdin
}()
// Create a pipe and close it immediately to simulate read error
r, w, _ := os.Pipe()
os.Stdin = r
r.Close()
w.Close()
// This should cause a read error
_, err := Prompt("Test?", false)
if err == nil {
t.Error("expected error when reading from closed stdin, got nil")
}
if !strings.Contains(err.Error(), "failed to prompt") {
t.Errorf("expected 'failed to prompt' error, got: %v", err)
}
}

View File

@@ -3,6 +3,8 @@ package installer
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/repo"
@@ -11,11 +13,25 @@ import (
// Run runs the installer
func Run(configs string, confpath string) error {
// expand template placeholders with platform-appropriate paths
configs = ExpandTemplate(configs, confpath)
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confdir := filepath.Dir(confpath)
// determine cheatsheet directory paths
community, personal, work := cheatsheetDirs(confpath)
// create paths for community and personal cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal")
// set default cheatpaths
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
// locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
}
// prompt the user to download the community cheatsheets
yes, err := Prompt(
@@ -28,17 +44,15 @@ func Run(configs string, confpath string) error {
// clone the community cheatsheets if so instructed
if yes {
// clone the community cheatsheets
fmt.Printf("Cloning community cheatsheets to %s.\n", community)
if err := repo.Clone(community); err != nil {
return fmt.Errorf("failed to clone cheatsheets: %v", err)
}
} else {
configs = CommentCommunity(configs, confpath)
}
// always create personal and work directories
for _, dir := range []string{personal, work} {
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
// also create a directory for personal cheatsheets
fmt.Printf("Cloning personal cheatsheets to %s.\n", personal)
if err := os.MkdirAll(personal, os.ModePerm); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
}

View File

@@ -1,267 +0,0 @@
package installer
import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestRun(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "cheat-installer-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Save original stdin/stdout
oldStdin := os.Stdin
oldStdout := os.Stdout
defer func() {
os.Stdin = oldStdin
os.Stdout = oldStdout
}()
tests := []struct {
name string
configs string
confpath string
userInput string
wantErr bool
wantInErr string
checkFiles []string
dontWantFiles []string
}{
{
name: "user declines community cheatsheets",
configs: `---
editor: EDITOR_PATH
pager: PAGER_PATH
cheatpaths:
- name: community
path: COMMUNITY_PATH
tags: [ community ]
readonly: true
- name: personal
path: PERSONAL_PATH
tags: [ personal ]
readonly: false
`,
confpath: filepath.Join(tempDir, "conf1", "conf.yml"),
userInput: "n\n",
wantErr: false,
checkFiles: []string{"conf1/conf.yml", "conf1/cheatsheets/personal", "conf1/cheatsheets/work"},
dontWantFiles: []string{"conf1/cheatsheets/community"},
},
{
name: "user accepts but clone fails",
configs: `---
cheatpaths:
- name: community
path: COMMUNITY_PATH
`,
confpath: filepath.Join(tempDir, "conf2", "conf.yml"),
userInput: "y\n",
wantErr: true,
wantInErr: "failed to clone cheatsheets",
},
{
name: "invalid config path",
configs: "test",
// /dev/null/... is truly uncreatable on Unix;
// NUL\... is uncreatable on Windows
confpath: func() string {
if runtime.GOOS == "windows" {
return `NUL\impossible\conf.yml`
}
return "/dev/null/impossible/conf.yml"
}(),
userInput: "n\n",
wantErr: true,
wantInErr: "failed to create",
},
}
// Pre-create a .git dir inside the community path so go-git's PlainClone
// returns ErrRepositoryAlreadyExists (otherwise, on CI runners with
// network access, the real clone succeeds and the test fails)
fakeGitDir := filepath.Join(tempDir, "conf2", "cheatsheets", "community", ".git")
if err := os.MkdirAll(fakeGitDir, 0755); err != nil {
t.Fatalf("failed to create fake .git dir: %v", err)
}
if err := os.WriteFile(filepath.Join(fakeGitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644); err != nil {
t.Fatalf("failed to write fake HEAD: %v", err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create stdin pipe
r, w, _ := os.Pipe()
os.Stdin = r
// Create stdout pipe to suppress output
_, wOut, _ := os.Pipe()
os.Stdout = wOut
// Write user input
go func() {
defer w.Close()
io.WriteString(w, tt.userInput)
}()
// Run the installer
err := Run(tt.configs, tt.confpath)
// Close pipes
wOut.Close()
// Check error
if (err != nil) != tt.wantErr {
t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil && tt.wantInErr != "" && !strings.Contains(err.Error(), tt.wantInErr) {
t.Errorf("Run() error = %v, want error containing %q", err, tt.wantInErr)
}
// Check created files
for _, file := range tt.checkFiles {
path := filepath.Join(tempDir, file)
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("expected file %s to exist, but it doesn't", path)
}
}
// Check files that shouldn't exist
for _, file := range tt.dontWantFiles {
path := filepath.Join(tempDir, file)
if _, err := os.Stat(path); err == nil {
t.Errorf("expected file %s to not exist, but it does", path)
}
}
})
}
}
func TestRunPromptError(t *testing.T) {
// Save original stdin
oldStdin := os.Stdin
defer func() {
os.Stdin = oldStdin
}()
// Close stdin to cause prompt error
r, w, _ := os.Pipe()
os.Stdin = r
r.Close()
w.Close()
tempDir, _ := os.MkdirTemp("", "cheat-installer-prompt-test-*")
defer os.RemoveAll(tempDir)
err := Run("test", filepath.Join(tempDir, "conf.yml"))
if err == nil {
t.Error("expected error when prompt fails, got nil")
}
if !strings.Contains(err.Error(), "failed to prompt") {
t.Errorf("expected 'failed to prompt' error, got: %v", err)
}
}
func TestRunStringReplacements(t *testing.T) {
// Test that path replacements work correctly
configs := `---
editor: EDITOR_PATH
pager: PAGER_PATH
cheatpaths:
- name: personal
path: PERSONAL_PATH
tags: [ personal ]
readonly: false
- name: work
path: WORK_PATH
tags: [ work ]
readonly: false
- name: community
path: COMMUNITY_PATH
tags: [ community ]
readonly: true
`
// Create temp directory
tempDir, err := os.MkdirTemp("", "cheat-installer-replace-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
confpath := filepath.Join(tempDir, "conf.yml")
confdir := filepath.Dir(confpath)
// Expected paths
expectedPersonal := filepath.Join(confdir, "cheatsheets", "personal")
// Save original stdin/stdout
oldStdin := os.Stdin
oldStdout := os.Stdout
defer func() {
os.Stdin = oldStdin
os.Stdout = oldStdout
}()
// Create stdin pipe with "n" answer
r, w, _ := os.Pipe()
os.Stdin = r
go func() {
defer w.Close()
io.WriteString(w, "n\n")
}()
// Suppress stdout
_, wOut, _ := os.Pipe()
os.Stdout = wOut
defer wOut.Close()
// Run installer
err = Run(configs, confpath)
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
// Read the created config file
content, err := os.ReadFile(confpath)
if err != nil {
t.Fatalf("failed to read config file: %v", err)
}
// Check replacements
contentStr := string(content)
if strings.Contains(contentStr, "COMMUNITY_PATH") {
t.Error("COMMUNITY_PATH was not replaced")
}
if strings.Contains(contentStr, "PERSONAL_PATH") {
t.Error("PERSONAL_PATH was not replaced")
}
if strings.Contains(contentStr, "EDITOR_PATH") {
t.Error("EDITOR_PATH was not replaced")
}
if strings.Contains(contentStr, "PAGER_PATH") {
t.Error("PAGER_PATH was not replaced")
}
if strings.Contains(contentStr, "WORK_PATH") {
t.Error("WORK_PATH was not replaced")
}
// Verify community path is commented out (user declined)
if strings.Contains(contentStr, " - name: community") {
t.Error("expected community cheatpath to be commented out when declined")
}
if !strings.Contains(contentStr, " #- name: community") {
t.Error("expected commented-out community cheatpath")
}
if !strings.Contains(contentStr, expectedPersonal) {
t.Errorf("expected personal path %q in config", expectedPersonal)
}
}

View File

@@ -1,58 +0,0 @@
package installer
import (
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config"
)
// cheatsheetDirs returns the community, personal, and work cheatsheet
// directory paths derived from a config file path.
func cheatsheetDirs(confpath string) (community, personal, work string) {
confdir := filepath.Dir(confpath)
community = filepath.Join(confdir, "cheatsheets", "community")
personal = filepath.Join(confdir, "cheatsheets", "personal")
work = filepath.Join(confdir, "cheatsheets", "work")
return
}
// ExpandTemplate replaces placeholder tokens in the config template with
// platform-appropriate paths derived from confpath.
func ExpandTemplate(configs string, confpath string) string {
community, personal, work := cheatsheetDirs(confpath)
// substitute paths
configs = strings.ReplaceAll(configs, "COMMUNITY_PATH", community)
configs = strings.ReplaceAll(configs, "PERSONAL_PATH", personal)
configs = strings.ReplaceAll(configs, "WORK_PATH", work)
// locate and set a default pager
configs = strings.ReplaceAll(configs, "PAGER_PATH", config.Pager())
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.ReplaceAll(configs, "EDITOR_PATH", editor)
}
return configs
}
// CommentCommunity comments out the community cheatpath block in the config
// template. This is used when the community cheatsheets directory won't exist
// (either because the user declined to download them, or because the config
// is being output as an example).
func CommentCommunity(configs string, confpath string) string {
community, _, _ := cheatsheetDirs(confpath)
return strings.ReplaceAll(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
)
}

View File

@@ -1,19 +1,25 @@
// Package mocks provides test fixture data and helpers for unit tests.
package mocks
package mock
import (
"fmt"
"path"
"path/filepath"
"runtime"
)
// Path returns the absolute path to the specified mock file within
// the mocks/ directory.
// Path returns the absolute path to the specified mock file.
func Path(filename string) string {
// determine the path of this file during runtime
_, thisfile, _, _ := runtime.Caller(0)
// compute the mock path
file, err := filepath.Abs(
filepath.Join(filepath.Dir(thisfile), filename),
path.Join(
filepath.Dir(thisfile),
"../../mocks",
filename,
),
)
if err != nil {
panic(fmt.Errorf("failed to resolve mock path: %v", err))

View File

@@ -1,4 +1,3 @@
// Package repo implements functions pertaining to the management of git repos.
package repo
import (
@@ -8,11 +7,11 @@ import (
"github.com/go-git/go-git/v5"
)
// Clone clones the community cheatsheets repository to the specified directory
func Clone(dir string) error {
// Clone clones the repo available at `url`
func Clone(url string) error {
// clone the community cheatsheets
_, err := git.PlainClone(dir, false, &git.CloneOptions{
_, err := git.PlainClone(url, false, &git.CloneOptions{
URL: "https://github.com/cheat/cheatsheets.git",
Depth: 1,
Progress: os.Stdout,

View File

@@ -1,80 +0,0 @@
//go:build integration
// +build integration
package repo
import (
"os"
"path/filepath"
"testing"
)
// TestCloneIntegration performs a real clone operation to verify functionality
// Run with: go test -tags=integration ./internal/repo -v -run TestCloneIntegration
func TestCloneIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Create a temporary directory
tmpDir, err := os.MkdirTemp("", "cheat-clone-integration-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
destDir := filepath.Join(tmpDir, "cheatsheets")
t.Logf("Cloning to: %s", destDir)
// Perform the actual clone
err = Clone(destDir)
if err != nil {
t.Fatalf("Clone() failed: %v", err)
}
// Verify the clone succeeded
info, err := os.Stat(destDir)
if err != nil {
t.Fatalf("destination directory not created: %v", err)
}
if !info.IsDir() {
t.Fatal("destination is not a directory")
}
// Check for .git directory
gitDir := filepath.Join(destDir, ".git")
if _, err := os.Stat(gitDir); err != nil {
t.Error(".git directory not found")
}
// Check for some expected cheatsheets
expectedFiles := []string{
"bash", // bash cheatsheet should exist
"git", // git cheatsheet should exist
"ls", // ls cheatsheet should exist
}
foundCount := 0
for _, file := range expectedFiles {
path := filepath.Join(destDir, file)
if _, err := os.Stat(path); err == nil {
foundCount++
}
}
if foundCount < 2 {
t.Errorf("expected at least 2 common cheatsheets, found %d", foundCount)
}
t.Log("Clone integration test passed!")
// Test cloning to existing directory (should fail)
err = Clone(destDir)
if err == nil {
t.Error("expected error when cloning to existing repository, got nil")
} else {
t.Logf("Expected error when cloning to existing dir: %v", err)
}
}

View File

@@ -1,53 +0,0 @@
package repo
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestClone tests the Clone function
func TestClone(t *testing.T) {
// This test requires network access, so we'll only test error cases
// that don't require actual cloning
t.Run("clone to read-only directory", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod does not restrict writes on Windows")
}
if os.Getuid() == 0 {
t.Skip("Cannot test read-only directory as root")
}
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "cheat-clone-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create a read-only subdirectory
readOnlyDir := filepath.Join(tempDir, "readonly")
if err := os.Mkdir(readOnlyDir, 0555); err != nil {
t.Fatalf("failed to create read-only dir: %v", err)
}
// Attempt to clone to read-only directory
targetDir := filepath.Join(readOnlyDir, "cheatsheets")
err = Clone(targetDir)
// Should fail because we can't write to read-only directory
if err == nil {
t.Error("expected error when cloning to read-only directory, got nil")
}
})
t.Run("clone to invalid path", func(t *testing.T) {
// Try to clone to a path with null bytes (invalid on most filesystems)
err := Clone("/tmp/invalid\x00path")
if err == nil {
t.Error("expected error with invalid path, got nil")
}
})
}

View File

@@ -6,11 +6,6 @@ import (
"strings"
)
// gitSep is the `.git` path component surrounded by path separators.
// Used to match `.git` as a complete path component, not as a suffix
// of a directory name (e.g., `personal.git`).
var gitSep = string(os.PathSeparator) + ".git" + string(os.PathSeparator)
// GitDir returns `true` if we are iterating over a directory contained within
// a repositories `.git` directory.
func GitDir(path string) (bool, error) {
@@ -55,20 +50,9 @@ func GitDir(path string) (bool, error) {
See: https://github.com/cheat/cheat/issues/699
Accounting for all of the above, the next solution was to search not
for `.git`, but `.git/` (including the directory separator), and then
only ceasing to walk the directory on a match.
This, however, also had a bug: searching for `.git/` also matched
directory names that *ended with* `.git`, like `personal.git/`. This
caused cheatsheets stored under such paths to be silently skipped.
See: https://github.com/cheat/cheat/issues/711
The current (and hopefully final) solution requires the path separator
on *both* sides of `.git`, i.e., searching for `/.git/`. This ensures
that `.git` is matched only as a complete path component, not as a
suffix of a directory name.
Accounting for all of the above (hopefully?), the current solution is
not to search for `.git`, but `.git/` (including the directory
separator), and then only ceasing to walk the directory on a match.
To summarize, this code must account for the following possibilities:
@@ -77,16 +61,17 @@ func GitDir(path string) (bool, error) {
3. A cheatpath is a repository, and contains a `.git*` file
4. A cheatpath is a submodule
5. A cheatpath is a hidden directory
6. A cheatpath is inside a directory whose name ends with `.git`
Care must be taken to support the above on both Unix and Windows
systems, which have different directory separators and line-endings.
NB: `filepath.Walk` always passes absolute paths to the walk function,
so `.git` will never appear as the first path component. This is what
makes the "separator on both sides" approach safe.
There is a lot of nuance to all of this, and it would be worthwhile to
do two things to stop writing bugs here:
A reasonable smoke-test for ensuring that skipping is being applied
1. Build integration tests around all of this
2. Discard string-matching solutions entirely, and use `go-git` instead
NB: A reasonable smoke-test for ensuring that skipping is being applied
correctly is to run the following command:
make && strace ./dist/cheat -l | wc -l
@@ -98,8 +83,8 @@ func GitDir(path string) (bool, error) {
of syscalls should be significantly lower with the skip check enabled.
*/
// determine if `.git` appears as a complete path component
pos := strings.Index(path, gitSep)
// determine if the literal string `.git` appears within `path`
pos := strings.Index(path, fmt.Sprintf(".git%s", string(os.PathSeparator)))
// if it does not, we know for certain that we are not within a `.git`
// directory.

View File

@@ -1,355 +0,0 @@
package repo
import (
"os"
"path/filepath"
"testing"
)
// setupGitDirTestTree creates a temporary directory structure that exercises
// every case documented in GitDir's comment block. The caller must defer
// os.RemoveAll on the returned root.
//
// Layout:
//
// root/
// ├── plain/ # not a repository
// │ └── sheet
// ├── repo/ # a repository (.git is a directory)
// │ ├── .git/
// │ │ ├── HEAD
// │ │ ├── objects/
// │ │ │ └── pack/
// │ │ └── refs/
// │ │ └── heads/
// │ ├── .gitignore
// │ ├── .gitattributes
// │ └── sheet
// ├── submodule/ # a submodule (.git is a file)
// │ ├── .git # file, not directory
// │ └── sheet
// ├── dotgit-suffix.git/ # directory name ends in .git (#711)
// │ └── cheat/
// │ └── sheet
// ├── dotgit-mid.git/ # .git suffix mid-path (#711)
// │ └── nested/
// │ └── sheet
// ├── .github/ # .github directory (not .git)
// │ └── workflows/
// │ └── ci.yml
// └── .hidden/ # generic hidden directory
// └── sheet
func setupGitDirTestTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
dirs := []string{
// case 1: not a repository
filepath.Join(root, "plain"),
// case 2: a repository (.git directory with contents)
filepath.Join(root, "repo", ".git", "objects", "pack"),
filepath.Join(root, "repo", ".git", "refs", "heads"),
// case 4: a submodule (.git is a file)
filepath.Join(root, "submodule"),
// case 6: directory name ending in .git (#711)
filepath.Join(root, "dotgit-suffix.git", "cheat"),
filepath.Join(root, "dotgit-mid.git", "nested"),
// .github (should not be confused with .git)
filepath.Join(root, ".github", "workflows"),
// generic hidden directory
filepath.Join(root, ".hidden"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("failed to create dir %s: %v", dir, err)
}
}
files := map[string]string{
// sheets
filepath.Join(root, "plain", "sheet"): "plain sheet",
filepath.Join(root, "repo", "sheet"): "repo sheet",
filepath.Join(root, "submodule", "sheet"): "submod sheet",
filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"): "dotgit sheet",
filepath.Join(root, "dotgit-mid.git", "nested", "sheet"): "dotgit nested",
filepath.Join(root, ".hidden", "sheet"): "hidden sheet",
// git metadata
filepath.Join(root, "repo", ".git", "HEAD"): "ref: refs/heads/main\n",
filepath.Join(root, "repo", ".gitignore"): "*.tmp\n",
filepath.Join(root, "repo", ".gitattributes"): "* text=auto\n",
filepath.Join(root, "submodule", ".git"): "gitdir: ../.git/modules/sub\n",
filepath.Join(root, ".github", "workflows", "ci.yml"): "name: CI\n",
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to write %s: %v", path, err)
}
}
return root
}
func TestGitDir(t *testing.T) {
root := setupGitDirTestTree(t)
tests := []struct {
name string
path string
want bool
}{
// Case 1: not a repository — no .git anywhere in path
{
name: "plain directory, no repo",
path: filepath.Join(root, "plain", "sheet"),
want: false,
},
// Case 2: a repository — paths *inside* .git/ should be detected
{
name: "inside .git directory",
path: filepath.Join(root, "repo", ".git", "HEAD"),
want: true,
},
{
name: "inside .git/objects",
path: filepath.Join(root, "repo", ".git", "objects", "pack", "somefile"),
want: true,
},
{
name: "inside .git/refs",
path: filepath.Join(root, "repo", ".git", "refs", "heads", "main"),
want: true,
},
// Case 2 (cont.): files *alongside* .git should NOT be detected
{
name: "sheet in repo root (beside .git dir)",
path: filepath.Join(root, "repo", "sheet"),
want: false,
},
// Case 3: .git* files (like .gitignore) should NOT trigger
{
name: ".gitignore file",
path: filepath.Join(root, "repo", ".gitignore"),
want: false,
},
{
name: ".gitattributes file",
path: filepath.Join(root, "repo", ".gitattributes"),
want: false,
},
// Case 4: submodule — .git is a file, not a directory
{
name: "sheet in submodule (where .git is a file)",
path: filepath.Join(root, "submodule", "sheet"),
want: false,
},
// Case 6: directory name ends with .git (#711)
{
name: "sheet under directory ending in .git",
path: filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
want: false,
},
{
name: "sheet under .git-suffixed dir, nested deeper",
path: filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
want: false,
},
// .github directory — must not be confused with .git
{
name: "file inside .github directory",
path: filepath.Join(root, ".github", "workflows", "ci.yml"),
want: false,
},
// Hidden directory that is not .git
{
name: "file inside generic hidden directory",
path: filepath.Join(root, ".hidden", "sheet"),
want: false,
},
// Path with no .git at all
{
name: "path with no .git component whatsoever",
path: filepath.Join(root, "nonexistent", "file"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GitDir(tt.path)
if err != nil {
t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
}
if got != tt.want {
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
// TestGitDirWithNestedGitDir tests a repo inside a .git-suffixed parent
// directory. This is the nastiest combination: a real .git directory that
// appears *after* a .git suffix in the path.
func TestGitDirWithNestedGitDir(t *testing.T) {
root := t.TempDir()
// Create: root/cheats.git/repo/.git/HEAD
// root/cheats.git/repo/sheet
gitDir := filepath.Join(root, "cheats.git", "repo", ".git")
if err := os.MkdirAll(gitDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "cheats.git", "repo", "sheet"), []byte("content"), 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
path string
want bool
}{
{
name: "sheet beside .git in .git-suffixed parent",
path: filepath.Join(root, "cheats.git", "repo", "sheet"),
want: false,
},
{
name: "file inside .git inside .git-suffixed parent",
path: filepath.Join(root, "cheats.git", "repo", ".git", "HEAD"),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GitDir(tt.path)
if err != nil {
t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
}
if got != tt.want {
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
// TestGitDirSubmoduleInsideDotGitSuffix tests a submodule (.git file)
// inside a .git-suffixed parent directory.
func TestGitDirSubmoduleInsideDotGitSuffix(t *testing.T) {
root := t.TempDir()
// Create: root/personal.git/submod/.git (file)
// root/personal.git/submod/sheet
subDir := filepath.Join(root, "personal.git", "submod")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
// .git as a file (submodule pointer)
if err := os.WriteFile(filepath.Join(subDir, ".git"), []byte("gitdir: ../../.git/modules/sub\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subDir, "sheet"), []byte("content"), 0644); err != nil {
t.Fatal(err)
}
got, err := GitDir(filepath.Join(subDir, "sheet"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got {
t.Error("GitDir should return false for sheet in submodule under .git-suffixed parent")
}
}
// TestGitDirIntegrationWalk simulates what sheets.Load does: walking a
// directory tree and checking each path with GitDir. This verifies that
// the function works correctly in the context of filepath.Walk, which is
// how it is actually called.
func TestGitDirIntegrationWalk(t *testing.T) {
root := setupGitDirTestTree(t)
// Walk the tree and collect which paths GitDir says to skip
var skipped []string
var visited []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
isGit, err := GitDir(path)
if err != nil {
return err
}
if isGit {
skipped = append(skipped, path)
} else {
visited = append(visited, path)
}
return nil
})
if err != nil {
t.Fatalf("Walk failed: %v", err)
}
// Files inside .git/ should be skipped
expectSkipped := []string{
filepath.Join(root, "repo", ".git", "HEAD"),
}
for _, want := range expectSkipped {
found := false
for _, got := range skipped {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("expected %q to be skipped, but it was not", want)
}
}
// Sheets should NOT be skipped — including the #711 case
expectVisited := []string{
filepath.Join(root, "plain", "sheet"),
filepath.Join(root, "repo", "sheet"),
filepath.Join(root, "submodule", "sheet"),
filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
filepath.Join(root, ".hidden", "sheet"),
}
for _, want := range expectVisited {
found := false
for _, got := range visited {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("expected %q to be visited (not skipped), but it was not found in visited paths", want)
}
}
}

View File

@@ -1,130 +0,0 @@
package repo
import (
"errors"
"os"
"path/filepath"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/mitchellh/go-homedir"
)
// ErrDirtyWorktree indicates that the worktree has uncommitted changes.
var ErrDirtyWorktree = errors.New("dirty worktree")
// Pull performs a git pull on the repository at path. It returns
// ErrDirtyWorktree if the worktree has uncommitted changes, and
// git.ErrRepositoryNotExists if path is not a git repository.
func Pull(path string) error {
// open the repository
r, err := git.PlainOpen(path)
if err != nil {
return err
}
// get the worktree
wt, err := r.Worktree()
if err != nil {
return err
}
// check if the worktree is clean
status, err := wt.Status()
if err != nil {
return err
}
if !status.IsClean() {
return ErrDirtyWorktree
}
// build pull options, using SSH auth when the remote is SSH
opts := &git.PullOptions{}
if auth, err := sshAuth(r); err == nil && auth != nil {
opts.Auth = auth
}
// pull
err = wt.Pull(opts)
if errors.Is(err, git.NoErrAlreadyUpToDate) {
return nil
}
return err
}
// defaultKeyFiles are the SSH key filenames tried in order, matching the
// default behavior of OpenSSH.
var defaultKeyFiles = []string{
"id_rsa",
"id_ecdsa",
"id_ecdsa_sk",
"id_ed25519",
"id_ed25519_sk",
"id_dsa",
}
// sshAuth returns an appropriate SSH auth method if the origin remote uses
// the SSH protocol, or nil if it does not. It tries the SSH agent first, then
// falls back to default key files in ~/.ssh/.
func sshAuth(r *git.Repository) (transport.AuthMethod, error) {
remote, err := r.Remote("origin")
if err != nil {
return nil, err
}
urls := remote.Config().URLs
if len(urls) == 0 {
return nil, nil
}
ep, err := transport.NewEndpoint(urls[0])
if err != nil {
return nil, err
}
if ep.Protocol != "ssh" {
return nil, nil
}
user := ep.User
if user == "" {
user = "git"
}
// try default key files first — this is more reliable than the SSH
// agent, which may report success even when no keys are loaded
home, err := homedir.Dir()
if err == nil {
if auth := findKeyFile(filepath.Join(home, ".ssh"), user); auth != nil {
return auth, nil
}
}
// fall back to SSH agent
if auth, err := gitssh.NewSSHAgentAuth(user); err == nil {
return auth, nil
}
return nil, nil
}
// findKeyFile looks for a usable SSH private key in sshDir, trying the
// standard OpenSSH default filenames in order. Returns nil if no usable key
// is found.
func findKeyFile(sshDir, user string) transport.AuthMethod {
for _, name := range defaultKeyFiles {
keyPath := filepath.Join(sshDir, name)
if _, err := os.Stat(keyPath); err != nil {
continue
}
auth, err := gitssh.NewPublicKeysFromFile(user, keyPath, "")
if err != nil {
continue
}
return auth
}
return nil
}

View File

@@ -1,315 +0,0 @@
package repo
import (
"crypto/ed25519"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
"time"
"github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/object"
)
// testCommitOpts returns a CommitOptions suitable for test commits.
func testCommitOpts() *git.CommitOptions {
return &git.CommitOptions{
Author: &object.Signature{
Name: "test",
Email: "test@test",
When: time.Now(),
},
}
}
// initBareRepoWithCommit creates a bare git repository at dir with an initial
// commit, suitable for use as a remote.
func initBareRepoWithCommit(t *testing.T, dir string) {
t.Helper()
// init a non-bare repo to make the commit, then we'll clone it as bare
tmpWork := t.TempDir()
r, err := git.PlainInit(tmpWork, false)
if err != nil {
t.Fatalf("failed to init repo: %v", err)
}
f := filepath.Join(tmpWork, "README")
if err := os.WriteFile(f, []byte("hello\n"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
wt, err := r.Worktree()
if err != nil {
t.Fatalf("failed to get worktree: %v", err)
}
if _, err := wt.Add("README"); err != nil {
t.Fatalf("failed to stage file: %v", err)
}
if _, err = wt.Commit("initial commit", testCommitOpts()); err != nil {
t.Fatalf("failed to commit: %v", err)
}
// clone as bare into the target dir
if _, err = git.PlainClone(dir, true, &git.CloneOptions{URL: tmpWork}); err != nil {
t.Fatalf("failed to create bare clone: %v", err)
}
}
// cloneLocal clones the bare repo at bareDir into a new working directory and
// returns the path.
func cloneLocal(t *testing.T, bareDir string) string {
t.Helper()
dir := t.TempDir()
_, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: bareDir,
})
if err != nil {
t.Fatalf("failed to clone: %v", err)
}
return dir
}
// pushNewCommit clones bareDir into a temporary working copy, commits a new
// file, and pushes back to the bare repo.
func pushNewCommit(t *testing.T, bareDir string) {
t.Helper()
tmpWork := t.TempDir()
r, err := git.PlainClone(tmpWork, false, &git.CloneOptions{URL: bareDir})
if err != nil {
t.Fatalf("failed to clone for push: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpWork, "new.txt"), []byte("new\n"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
wt, err := r.Worktree()
if err != nil {
t.Fatalf("failed to get worktree: %v", err)
}
if _, err := wt.Add("new.txt"); err != nil {
t.Fatalf("failed to stage file: %v", err)
}
if _, err := wt.Commit("add new file", testCommitOpts()); err != nil {
t.Fatalf("failed to commit: %v", err)
}
if err := r.Push(&git.PushOptions{}); err != nil {
t.Fatalf("failed to push: %v", err)
}
}
// generateTestKey creates an unencrypted ed25519 PEM private key file at path.
func generateTestKey(t *testing.T, path string) {
t.Helper()
_, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("failed to generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("failed to marshal key: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err := os.WriteFile(path, pemBytes, 0600); err != nil {
t.Fatalf("failed to write key file: %v", err)
}
}
// --- Pull tests ---
func TestPull_NotARepo(t *testing.T) {
dir := t.TempDir()
err := Pull(dir)
if err != git.ErrRepositoryNotExists {
t.Fatalf("expected ErrRepositoryNotExists, got: %v", err)
}
}
func TestPull_CleanAlreadyUpToDate(t *testing.T) {
bare := t.TempDir()
initBareRepoWithCommit(t, bare)
clone := cloneLocal(t, bare)
err := Pull(clone)
if err != nil {
t.Fatalf("expected nil (already up-to-date), got: %v", err)
}
}
func TestPull_NewUpstreamChanges(t *testing.T) {
bare := t.TempDir()
initBareRepoWithCommit(t, bare)
clone := cloneLocal(t, bare)
// push a new commit to the bare repo after the clone
pushNewCommit(t, bare)
err := Pull(clone)
if err != nil {
t.Fatalf("expected nil (successful pull), got: %v", err)
}
// verify the new file was pulled
if _, err := os.Stat(filepath.Join(clone, "new.txt")); err != nil {
t.Fatalf("expected new.txt to exist after pull: %v", err)
}
}
func TestPull_DirtyWorktree(t *testing.T) {
bare := t.TempDir()
initBareRepoWithCommit(t, bare)
clone := cloneLocal(t, bare)
// make the worktree dirty with a modified tracked file
if err := os.WriteFile(filepath.Join(clone, "README"), []byte("changed\n"), 0644); err != nil {
t.Fatalf("failed to modify file: %v", err)
}
err := Pull(clone)
if err != ErrDirtyWorktree {
t.Fatalf("expected ErrDirtyWorktree, got: %v", err)
}
}
func TestPull_DirtyWorktreeUntracked(t *testing.T) {
bare := t.TempDir()
initBareRepoWithCommit(t, bare)
clone := cloneLocal(t, bare)
// make the worktree dirty with an untracked file
if err := os.WriteFile(filepath.Join(clone, "untracked.txt"), []byte("new\n"), 0644); err != nil {
t.Fatalf("failed to create file: %v", err)
}
err := Pull(clone)
if err != ErrDirtyWorktree {
t.Fatalf("expected ErrDirtyWorktree, got: %v", err)
}
}
// --- sshAuth tests ---
func TestSshAuth_NonSSHRemote(t *testing.T) {
bare := t.TempDir()
initBareRepoWithCommit(t, bare)
clone := cloneLocal(t, bare)
r, err := git.PlainOpen(clone)
if err != nil {
t.Fatalf("failed to open repo: %v", err)
}
// the clone's origin is a local file:// path, not SSH
auth, err := sshAuth(r)
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if auth != nil {
t.Fatalf("expected nil auth for non-SSH remote, got: %v", auth)
}
}
func TestSshAuth_NoRemote(t *testing.T) {
dir := t.TempDir()
r, err := git.PlainInit(dir, false)
if err != nil {
t.Fatalf("failed to init repo: %v", err)
}
// repo has no remotes
auth, err := sshAuth(r)
if err == nil {
t.Fatalf("expected error for missing remote, got auth: %v", auth)
}
}
func TestSshAuth_SSHRemote(t *testing.T) {
dir := t.TempDir()
r, err := git.PlainInit(dir, false)
if err != nil {
t.Fatalf("failed to init repo: %v", err)
}
// add an SSH remote
_, err = r.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{"git@github.com:example/repo.git"},
})
if err != nil {
t.Fatalf("failed to create remote: %v", err)
}
// sshAuth should not return an error — even if no key is found, it
// returns (nil, nil) rather than an error
auth, err := sshAuth(r)
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
// we can't predict whether auth is nil or non-nil here because it
// depends on whether the test runner has SSH keys or an agent; just
// verify it didn't error
_ = auth
}
// --- findKeyFile tests ---
func TestFindKeyFile_ValidKey(t *testing.T) {
sshDir := t.TempDir()
generateTestKey(t, filepath.Join(sshDir, "id_ed25519"))
auth := findKeyFile(sshDir, "git")
if auth == nil {
t.Fatal("expected non-nil auth for valid key file")
}
}
func TestFindKeyFile_NoKeys(t *testing.T) {
sshDir := t.TempDir()
auth := findKeyFile(sshDir, "git")
if auth != nil {
t.Fatalf("expected nil auth for empty directory, got: %v", auth)
}
}
func TestFindKeyFile_InvalidKey(t *testing.T) {
sshDir := t.TempDir()
// write garbage into a file named like a key
if err := os.WriteFile(filepath.Join(sshDir, "id_ed25519"), []byte("not a key"), 0600); err != nil {
t.Fatalf("failed to write file: %v", err)
}
auth := findKeyFile(sshDir, "git")
if auth != nil {
t.Fatalf("expected nil auth for invalid key file, got: %v", auth)
}
}
func TestFindKeyFile_SkipsInvalidFindsValid(t *testing.T) {
sshDir := t.TempDir()
// put garbage in id_rsa (tried first), valid key in id_ed25519 (tried later)
if err := os.WriteFile(filepath.Join(sshDir, "id_rsa"), []byte("not a key"), 0600); err != nil {
t.Fatalf("failed to write file: %v", err)
}
generateTestKey(t, filepath.Join(sshDir, "id_ed25519"))
auth := findKeyFile(sshDir, "git")
if auth == nil {
t.Fatal("expected non-nil auth; should skip invalid id_rsa and find id_ed25519")
}
}

1
internal/repo/update.go Normal file
View File

@@ -0,0 +1 @@
package repo

View File

@@ -5,7 +5,7 @@ import (
"github.com/cheat/cheat/internal/config"
"github.com/alecthomas/chroma/v2/quick"
"github.com/alecthomas/chroma/quick"
)
// Colorize applies syntax-highlighting to a cheatsheet's Text.

View File

@@ -1,7 +1,6 @@
package sheet
import (
"strings"
"testing"
"github.com/cheat/cheat/internal/config"
@@ -17,78 +16,19 @@ func TestColorize(t *testing.T) {
}
// mock a sheet
original := "echo 'foo'"
s := Sheet{
Text: original,
Text: "echo 'foo'",
}
// colorize the sheet text
s.Colorize(conf)
// assert that the text was modified (colorization applied)
if s.Text == original {
t.Error("Colorize did not modify sheet text")
}
// initialize expectations
want := "echo"
want += " 'foo'"
// assert that ANSI escape codes are present
if !strings.Contains(s.Text, "\x1b[") && !strings.Contains(s.Text, "[0m") {
t.Errorf("colorized text does not contain ANSI escape codes: %q", s.Text)
}
// assert that the original content is still present within the colorized output
if !strings.Contains(s.Text, "echo") || !strings.Contains(s.Text, "foo") {
t.Errorf("colorized text lost original content: %q", s.Text)
}
}
// TestColorizeDefaultSyntax asserts that when no syntax is specified, the
// default ("bash") is used and produces the same output as an explicit "bash"
func TestColorizeDefaultSyntax(t *testing.T) {
conf := config.Config{
Formatter: "terminal16m",
Style: "monokai",
}
// use bash-specific content that tokenizes differently across lexers
code := "if [[ -f /etc/passwd ]]; then\n echo \"found\" | grep -o found\nfi"
// colorize with empty syntax (should default to "bash")
noSyntax := Sheet{Text: code}
noSyntax.Colorize(conf)
// colorize with explicit "bash" syntax
bashSyntax := Sheet{Text: code, Syntax: "bash"}
bashSyntax.Colorize(conf)
// both should produce the same output
if noSyntax.Text != bashSyntax.Text {
t.Errorf(
"default syntax does not match explicit bash:\ndefault: %q\nexplicit: %q",
noSyntax.Text,
bashSyntax.Text,
)
}
}
// TestColorizeExplicitSyntax asserts that a specified syntax is used
func TestColorizeExplicitSyntax(t *testing.T) {
conf := config.Config{
Formatter: "terminal16m",
Style: "monokai",
}
// colorize as bash
bashSheet := Sheet{Text: "def hello():\n pass", Syntax: "bash"}
bashSheet.Colorize(conf)
// colorize as python
pySheet := Sheet{Text: "def hello():\n pass", Syntax: "python"}
pySheet.Colorize(conf)
// different lexers should produce different output for Python code
if bashSheet.Text == pySheet.Text {
t.Error("bash and python syntax produced identical output")
// assert
if s.Text != want {
t.Errorf("failed to colorize sheet: want: %s, got: %s", want, s.Text)
}
}

View File

@@ -39,8 +39,6 @@ func (s *Sheet) Copy(dest string) error {
// copy file contents
_, err = io.Copy(outfile, infile)
if err != nil {
// Clean up the partially written file on error
os.Remove(dest)
return fmt.Errorf(
"failed to copy file: infile: %s, outfile: %s, err: %v",
s.Path,

View File

@@ -1,145 +0,0 @@
package sheet
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestCopyErrors tests error cases for the Copy method
func TestCopyErrors(t *testing.T) {
tests := []struct {
name string
setup func() (*Sheet, string, func())
}{
{
name: "source file does not exist",
setup: func() (*Sheet, string, func()) {
sheet := &Sheet{
Title: "test",
Path: "/non/existent/file.txt",
CheatPath: "test",
}
dest := filepath.Join(os.TempDir(), "copy-test-dest.txt")
cleanup := func() {
os.Remove(dest)
}
return sheet, dest, cleanup
},
},
{
name: "destination directory creation fails",
setup: func() (*Sheet, string, func()) {
src, err := os.CreateTemp("", "copy-test-src-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
src.WriteString("test content")
src.Close()
sheet := &Sheet{
Title: "test",
Path: src.Name(),
CheatPath: "test",
}
blockerFile := filepath.Join(os.TempDir(), "copy-blocker-file")
if err := os.WriteFile(blockerFile, []byte("blocker"), 0644); err != nil {
t.Fatalf("failed to create blocker file: %v", err)
}
dest := filepath.Join(blockerFile, "subdir", "dest.txt")
cleanup := func() {
os.Remove(src.Name())
os.Remove(blockerFile)
}
return sheet, dest, cleanup
},
},
{
name: "destination file creation fails",
setup: func() (*Sheet, string, func()) {
src, err := os.CreateTemp("", "copy-test-src-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
src.WriteString("test content")
src.Close()
sheet := &Sheet{
Title: "test",
Path: src.Name(),
CheatPath: "test",
}
destDir := filepath.Join(os.TempDir(), "copy-test-dir")
if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
t.Fatalf("failed to create dest dir: %v", err)
}
cleanup := func() {
os.Remove(src.Name())
os.RemoveAll(destDir)
}
return sheet, destDir, cleanup
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sheet, dest, cleanup := tt.setup()
defer cleanup()
err := sheet.Copy(dest)
if err == nil {
t.Error("Copy() expected error, got nil")
}
})
}
}
// TestCopyUnreadableSource verifies that Copy returns an error when the source
// file cannot be opened (e.g., permission denied).
func TestCopyUnreadableSource(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod does not restrict reads on Windows")
}
src, err := os.CreateTemp("", "copy-test-unreadable-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(src.Name())
if _, err := src.WriteString("test content"); err != nil {
t.Fatalf("failed to write content: %v", err)
}
src.Close()
sheet := &Sheet{
Title: "test",
Path: src.Name(),
CheatPath: "test",
}
dest := filepath.Join(os.TempDir(), "copy-unreadable-test.txt")
defer os.Remove(dest)
if err := os.Chmod(src.Name(), 0000); err != nil {
t.Skip("Cannot change file permissions on this platform")
}
defer os.Chmod(src.Name(), 0644)
err = sheet.Copy(dest)
if err == nil {
t.Error("expected Copy to fail with permission error")
}
// Destination should not exist since the error occurs before it is created
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Error("destination file should not exist after open failure")
}
}

View File

@@ -2,17 +2,18 @@ package sheet
import (
"fmt"
"runtime"
"strings"
"gopkg.in/yaml.v3"
"gopkg.in/yaml.v2"
)
// Parse parses cheatsheet frontmatter
func parse(markdown string) (frontmatter, string, error) {
// detect the line-break style used in the content
// determine the appropriate line-break for the platform
linebreak := "\n"
if strings.Contains(markdown, "\r\n") {
if runtime.GOOS == "windows" {
linebreak = "\r\n"
}

View File

@@ -1,29 +0,0 @@
package sheet
import (
"testing"
)
// TestParseWindowsLineEndings tests parsing with Windows line endings
func TestParseWindowsLineEndings(t *testing.T) {
// stub our cheatsheet content with Windows line endings
markdown := "---\r\nsyntax: go\r\ntags: [ test ]\r\n---\r\nTo foo the bar: baz"
// parse the frontmatter
fm, text, err := parse(markdown)
// assert expectations
if err != nil {
t.Errorf("failed to parse markdown: %v", err)
}
want := "To foo the bar: baz"
if text != want {
t.Errorf("failed to parse text: want: %s, got: %s", want, text)
}
want = "go"
if fm.Syntax != want {
t.Errorf("failed to parse syntax: want: %s, got: %s", want, fm.Syntax)
}
}

View File

@@ -1,132 +0,0 @@
package sheet
import (
"strings"
"testing"
)
// FuzzParse tests the parse function with fuzzing to uncover edge cases
// and potential panics in YAML frontmatter parsing
func FuzzParse(f *testing.F) {
// Add seed corpus with various valid and edge case inputs
// Valid frontmatter
f.Add("---\nsyntax: go\n---\nContent")
f.Add("---\ntags: [a, b]\n---\n")
f.Add("---\nsyntax: bash\ntags: [linux, shell]\n---\n#!/bin/bash\necho hello")
// No frontmatter
f.Add("No frontmatter here")
f.Add("")
f.Add("Just plain text\nwith multiple lines")
// Edge cases with delimiters
f.Add("---")
f.Add("---\n")
f.Add("---\n---")
f.Add("---\n---\n")
f.Add("---\n---\n---")
f.Add("---\n---\n---\n---")
f.Add("------\n------")
// Invalid YAML
f.Add("---\n{invalid yaml\n---\n")
f.Add("---\nsyntax: \"unclosed quote\n---\n")
f.Add("---\ntags: [a, b,\n---\n")
// Windows line endings
f.Add("---\r\nsyntax: go\r\n---\r\nContent")
f.Add("---\r\n---\r\n")
// Mixed line endings
f.Add("---\nsyntax: go\r\n---\nContent")
f.Add("---\r\nsyntax: go\n---\r\nContent")
// Unicode and special characters
f.Add("---\ntags: [emoji, 🎉]\n---\n")
f.Add("---\nsyntax: 中文\n---\n")
f.Add("---\ntags: [\x00, \x01]\n---\n")
// Very long inputs
f.Add("---\ntags: [" + strings.Repeat("a,", 1000) + "a]\n---\n")
f.Add("---\n" + strings.Repeat("field: value\n", 1000) + "---\n")
// Nested structures
f.Add("---\ntags:\n - nested\n - list\n---\n")
f.Add("---\nmeta:\n author: test\n version: 1.0\n---\n")
f.Fuzz(func(t *testing.T, input string) {
// The parse function should never panic, regardless of input
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("parse panicked with input %q: %v", input, r)
}
}()
fm, text, err := parse(input)
// Verify invariants
if err == nil {
// If parsing succeeded, validate the result
// The returned text should be a suffix of the input
// (either the whole input if no frontmatter, or the part after frontmatter)
if !strings.HasSuffix(input, text) && text != input {
t.Errorf("returned text %q is not a valid suffix of input %q", text, input)
}
// If input starts with delimiter and has valid frontmatter,
// text should be shorter than input
if strings.HasPrefix(input, "---\n") || strings.HasPrefix(input, "---\r\n") {
if len(fm.Tags) > 0 || fm.Syntax != "" {
// We successfully parsed frontmatter, so text should be shorter
if len(text) >= len(input) {
t.Errorf("text length %d should be less than input length %d when frontmatter is parsed",
len(text), len(input))
}
}
}
// Note: Tags can be nil when frontmatter is not present or empty
// This is expected behavior in Go for uninitialized slices
} else {
// If parsing failed, the original input should be returned as text
if text != input {
t.Errorf("on error, text should equal input: got %q, want %q", text, input)
}
}
}()
})
}
// FuzzParseDelimiterHandling specifically tests delimiter edge cases
func FuzzParseDelimiterHandling(f *testing.F) {
// Seed corpus focusing on delimiter variations
f.Add("---", "content")
f.Add("", "---")
f.Add("---", "---")
f.Add("", "")
f.Fuzz(func(t *testing.T, prefix string, suffix string) {
// Build input with controllable parts around delimiters
inputs := []string{
prefix + "---\n" + suffix,
prefix + "---\r\n" + suffix,
prefix + "---\n---\n" + suffix,
prefix + "---\r\n---\r\n" + suffix,
prefix + "---\n" + "yaml: data\n" + "---\n" + suffix,
}
for _, input := range inputs {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("parse panicked with constructed input: %v", r)
}
}()
_, _, _ = parse(input)
}()
}
})
}

View File

@@ -38,7 +38,7 @@ To foo the bar: baz`
t.Errorf("failed to parse tags: want: %s, got: %s", want, fm.Tags[0])
}
if len(fm.Tags) != 1 {
t.Errorf("failed to parse tags: want: len 1, got: len %d", len(fm.Tags))
t.Errorf("failed to parse tags: want: len 0, got: len %d", len(fm.Tags))
}
}
@@ -93,20 +93,3 @@ To foo the bar: baz`
t.Errorf("failed to parse text: want: %s, got: %s", markdown, text)
}
}
// TestHasMalformedYAML asserts that an error is returned when the frontmatter
// contains invalid YAML that cannot be unmarshalled
func TestHasMalformedYAML(t *testing.T) {
// stub cheatsheet content with syntactically invalid YAML between the
// delimiters (a bare tab character followed by unquoted colon)
markdown := "---\n\t:\t:\n---\nBody text here"
// parse the frontmatter
_, _, err := parse(markdown)
// assert that an error was returned due to YAML unmarshal failure
if err == nil {
t.Error("failed to error on malformed YAML frontmatter")
}
}

View File

@@ -9,17 +9,16 @@ import (
func (s *Sheet) Search(reg *regexp.Regexp) string {
// record matches
var matches []string
matches := ""
// search through the cheatsheet's text line by line
for _, line := range strings.Split(s.Text, "\n\n") {
// save matching lines
// exit early if the line doesn't match the regex
if reg.MatchString(line) {
matches = append(matches, line)
matches += line + "\n\n"
}
}
// Join matches with the same delimiter used for splitting
return strings.Join(matches, "\n\n")
return strings.TrimSpace(matches)
}

View File

@@ -1,124 +0,0 @@
package sheet
import (
"regexp"
"strings"
"testing"
"time"
)
// FuzzSearchRegex tests the regex compilation and search functionality
// to ensure it handles malformed patterns gracefully and doesn't suffer
// from catastrophic backtracking
func FuzzSearchRegex(f *testing.F) {
// Add seed corpus with various regex patterns
// Valid patterns
f.Add("test", "This is a test string")
f.Add("(?i)test", "This is a TEST string")
f.Add("foo|bar", "foo and bar")
f.Add("^start", "start of line\nnext line")
f.Add("end$", "at the end\nnext line")
f.Add("\\d+", "123 numbers 456")
f.Add("[a-z]+", "lowercase UPPERCASE")
// Edge cases and potentially problematic patterns
f.Add("", "empty pattern")
f.Add(".", "any character")
f.Add(".*", "match everything")
f.Add(".+", "match something")
f.Add("\\", "backslash")
f.Add("(", "unclosed paren")
f.Add(")", "unmatched paren")
f.Add("[", "unclosed bracket")
f.Add("]", "unmatched bracket")
f.Add("[^]", "negated empty class")
f.Add("(?", "incomplete group")
// Patterns that might cause performance issues
f.Add("(a+)+", "aaaaaaaaaaaaaaaaaaaaaaaab")
f.Add("(a*)*", "aaaaaaaaaaaaaaaaaaaaaaaab")
f.Add("(a|a)*", "aaaaaaaaaaaaaaaaaaaaaaaab")
f.Add("(.*)*", "any text here")
f.Add("(\\d+)+", "123456789012345678901234567890x")
// Unicode patterns
f.Add("☺", "Unicode ☺ smiley")
f.Add("[一-龯]", "Chinese 中文 characters")
f.Add("\\p{L}+", "Unicode letters")
// Very long patterns
f.Add(strings.Repeat("a", 1000), "long pattern")
f.Add(strings.Repeat("(a|b)", 100), "complex pattern")
f.Fuzz(func(t *testing.T, pattern string, text string) {
// Test 1: Regex compilation should not panic
var reg *regexp.Regexp
var compileErr error
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("regexp.Compile panicked with pattern %q: %v", pattern, r)
}
}()
reg, compileErr = regexp.Compile(pattern)
}()
// If compilation failed, that's OK - we're testing error handling
if compileErr != nil {
// This is expected for invalid patterns
return
}
// Test 2: Create a sheet and test Search method
sheet := Sheet{
Title: "test",
Text: text,
}
// Search should not panic
var result string
done := make(chan bool, 1)
go func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Search panicked with pattern %q on text %q: %v", pattern, text, r)
}
done <- true
}()
result = sheet.Search(reg)
}()
// Timeout after 100ms to catch catastrophic backtracking
select {
case <-done:
// Search completed successfully
case <-time.After(100 * time.Millisecond):
t.Errorf("Search timed out (possible catastrophic backtracking) with pattern %q on text %q", pattern, text)
}
// Test 3: Verify search result invariants
if result != "" {
// The Search function splits by "\n\n", so we need to compare using the same logic
resultLines := strings.Split(result, "\n\n")
textLines := strings.Split(text, "\n\n")
// Every result line should exist in the original text lines
for _, rLine := range resultLines {
found := false
for _, tLine := range textLines {
if rLine == tLine {
found = true
break
}
}
if !found && rLine != "" {
t.Errorf("Search result contains line not in original text: %q", rLine)
}
}
}
})
}

View File

@@ -1,5 +1,3 @@
// Package sheet implements functions pertaining to parsing, searching, and
// displaying cheatsheets.
package sheet
import (

View File

@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/cheat/cheat/mocks"
"github.com/cheat/cheat/internal/mock"
)
// TestSheetSuccess asserts that sheets initialize properly
@@ -14,7 +14,7 @@ func TestSheetSuccess(t *testing.T) {
sheet, err := New(
"foo",
"community",
mocks.Path("sheet/foo"),
mock.Path("sheet/foo"),
[]string{"alpha", "bravo"},
false,
)
@@ -27,10 +27,10 @@ func TestSheetSuccess(t *testing.T) {
t.Errorf("failed to init title: want: foo, got: %s", sheet.Title)
}
if sheet.Path != mocks.Path("sheet/foo") {
if sheet.Path != mock.Path("sheet/foo") {
t.Errorf(
"failed to init path: want: %s, got: %s",
mocks.Path("sheet/foo"),
mock.Path("sheet/foo"),
sheet.Path,
)
}
@@ -63,7 +63,7 @@ func TestSheetFailure(t *testing.T) {
_, err := New(
"foo",
"community",
mocks.Path("/does-not-exist"),
mock.Path("/does-not-exist"),
[]string{"alpha", "bravo"},
false,
)
@@ -80,7 +80,7 @@ func TestSheetFrontMatterFailure(t *testing.T) {
_, err := New(
"foo",
"community",
mocks.Path("sheet/bad-fm"),
mock.Path("sheet/bad-fm"),
[]string{"alpha", "bravo"},
false,
)

View File

@@ -1,8 +1,15 @@
package sheet
import "slices"
// Tagged returns true if a sheet was tagged with `needle`
func (s *Sheet) Tagged(needle string) bool {
return slices.Contains(s.Tags, needle)
// if any of the tags match `needle`, return `true`
for _, tag := range s.Tags {
if tag == needle {
return true
}
}
// otherwise, return `false`
return false
}

View File

@@ -1,94 +0,0 @@
package sheet
import (
"strings"
"testing"
)
// FuzzTagged tests the Tagged function with potentially malicious tag inputs
//
// Threat model: An attacker crafts a malicious cheatsheet with specially
// crafted tags that could cause issues when a user searches/filters by tags.
// This is particularly relevant for shared community cheatsheets.
func FuzzTagged(f *testing.F) {
// Add seed corpus with potentially problematic inputs
// These represent tags an attacker might use in a malicious cheatsheet
f.Add("normal", "normal")
f.Add("", "")
f.Add(" ", " ")
f.Add("\n", "\n")
f.Add("\r\n", "\r\n")
f.Add("\x00", "\x00") // Null byte
f.Add("../../etc/passwd", "../../etc/passwd") // Path traversal attempt
f.Add("'; DROP TABLE sheets;--", "sql") // SQL injection attempt
f.Add("<script>alert('xss')</script>", "xss") // XSS attempt
f.Add("${HOME}", "${HOME}") // Environment variable
f.Add("$(whoami)", "$(whoami)") // Command substitution
f.Add("`date`", "`date`") // Command substitution
f.Add("\\x41\\x42", "\\x41\\x42") // Escape sequences
f.Add("%00", "%00") // URL encoded null
f.Add("tag\nwith\nnewlines", "tag")
f.Add(strings.Repeat("a", 10000), "a") // Very long tag
f.Add("🎉", "🎉") // Unicode
f.Add("\U0001F4A9", "\U0001F4A9") // Unicode poop emoji
f.Add("tag with spaces", "tag with spaces")
f.Add("TAG", "tag") // Case sensitivity check
f.Add("tag", "TAG") // Case sensitivity check
f.Fuzz(func(t *testing.T, sheetTag string, searchTag string) {
// Create a sheet with the potentially malicious tag
sheet := Sheet{
Title: "test",
Tags: []string{sheetTag},
}
// The Tagged function should never panic regardless of input
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Tagged panicked with sheetTag=%q, searchTag=%q: %v",
sheetTag, searchTag, r)
}
}()
result := sheet.Tagged(searchTag)
// Verify the result is consistent with a simple string comparison
expected := false
for _, tag := range sheet.Tags {
if tag == searchTag {
expected = true
break
}
}
if result != expected {
t.Errorf("Tagged returned %v but expected %v for sheetTag=%q, searchTag=%q",
result, expected, sheetTag, searchTag)
}
// Additional invariant: Tagged should be case-sensitive
if sheetTag != searchTag && result {
t.Errorf("Tagged matched different strings: sheetTag=%q, searchTag=%q",
sheetTag, searchTag)
}
}()
// Test with multiple tags including the fuzzed one
sheetMulti := Sheet{
Title: "test",
Tags: []string{"safe1", sheetTag, "safe2", sheetTag}, // Duplicate tags
}
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Tagged panicked with multiple tags including %q: %v",
sheetTag, r)
}
}()
_ = sheetMulti.Tagged(searchTag)
}()
})
}

Some files were not shown because too many files have changed in this diff Show More