mirror of
https://github.com/cheat/cheat.git
synced 2026-03-07 11:13:33 +01:00
Compare commits
16 Commits
4.5.1
...
chore/hous
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecfb83a3b0 | ||
|
|
9440b4f816 | ||
|
|
971be88150 | ||
|
|
d4a8a79628 | ||
|
|
007c9f9efe | ||
|
|
f61203ac1b | ||
|
|
f1db4ee378 | ||
|
|
366d63afdc | ||
|
|
c1551683a3 | ||
|
|
09aad6f8ea | ||
|
|
adb5a43810 | ||
|
|
cab039a9d8 | ||
|
|
97e80beceb | ||
|
|
1969423b5c | ||
|
|
4497ce1b84 | ||
|
|
5eee02bc40 |
19
.github/workflows/homebrew.yml
vendored
19
.github/workflows/homebrew.yml
vendored
@@ -1,19 +0,0 @@
|
||||
---
|
||||
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 }}
|
||||
@@ -85,7 +85,11 @@ The `cheat` command-line tool is organized into several key packages:
|
||||
- Writes to stdout or pager
|
||||
- Handles text formatting and indentation
|
||||
|
||||
6. **`internal/repo`**: Git repository management
|
||||
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
|
||||
|
||||
@@ -95,6 +99,7 @@ The `cheat` command-line tool is organized into several key packages:
|
||||
- **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
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
Contributing
|
||||
============
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in `cheat`.
|
||||
|
||||
@@ -11,4 +10,8 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
[issues]: https://github.com/cheat/cheat/issues
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
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/4.5.1/cheat-linux-amd64.gz \
|
||||
&& wget https://github.com/cheat/cheat/releases/download/4.7.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 (`4.5.1`) and the archive
|
||||
You may need to need to change the version number (`4.7.0`) and the archive
|
||||
(`cheat-linux-amd64.gz`) depending on your platform.
|
||||
|
||||
See the [releases page][releases] for a list of supported platforms.
|
||||
|
||||
#### Windows
|
||||
### Windows
|
||||
On Windows, download the appropriate binary from the [releases page][releases],
|
||||
unzip the archive, and place the `cheat.exe` executable on your `PATH`.
|
||||
|
||||
### Install via `go install`
|
||||
## Install via `go install`
|
||||
If you have `go` version `>=1.17` available on your `PATH`, you can install
|
||||
`cheat` via `go install`:
|
||||
|
||||
@@ -32,7 +31,7 @@ If you have `go` version `>=1.17` available on your `PATH`, you can install
|
||||
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)
|
||||
@@ -43,8 +42,6 @@ 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`:
|
||||
1. A config file must be generated
|
||||
@@ -56,7 +53,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
|
||||
|
||||
1
Makefile
1
Makefile
@@ -27,6 +27,7 @@ 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
|
||||
|
||||
52
README.md
52
README.md
@@ -1,8 +1,6 @@
|
||||

|
||||
|
||||
|
||||
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
|
||||
@@ -13,9 +11,7 @@ remember.
|
||||
|
||||
Use `cheat` with [cheatsheets][].
|
||||
|
||||
|
||||
Example
|
||||
-------
|
||||
## Example
|
||||
The next time you're forced to disarm a nuclear weapon without consulting
|
||||
Google, you may run:
|
||||
|
||||
@@ -42,8 +38,10 @@ tar -xjvf '/path/to/foo.tgz'
|
||||
tar -cjvf '/path/to/foo.tgz' '/path/to/foo/'
|
||||
```
|
||||
|
||||
Usage
|
||||
-----
|
||||
## Installing
|
||||
For installation and configuration instructions, see [INSTALLING.md][].
|
||||
|
||||
## Usage
|
||||
To view a cheatsheet:
|
||||
|
||||
```sh
|
||||
@@ -70,6 +68,12 @@ 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
|
||||
@@ -101,14 +105,7 @@ 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}'
|
||||
```
|
||||
|
||||
|
||||
|
||||
Installing
|
||||
----------
|
||||
For installation and configuration instructions, see [INSTALLING.md][].
|
||||
|
||||
Cheatsheets
|
||||
-----------
|
||||
## Cheatsheets
|
||||
Cheatsheets are plain-text files with no file extension, and are named
|
||||
according to the command used to view them:
|
||||
|
||||
@@ -129,12 +126,15 @@ 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,14 +166,15 @@ 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` folder in
|
||||
the current working directory. If found, the `.cheat` directory will
|
||||
(temporarily) be added to the cheatpaths.
|
||||
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.
|
||||
|
||||
Autocompletion
|
||||
--------------
|
||||
## 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
|
||||
@@ -189,5 +190,6 @@ Additionally, `cheat` supports enhanced autocompletion via integration with
|
||||
[Releases]: https://github.com/cheat/cheat/releases
|
||||
[cheatsheets]: https://github.com/cheat/cheatsheets
|
||||
[completions]: https://github.com/cheat/cheat/tree/master/scripts
|
||||
[Chroma]: https://github.com/alecthomas/chroma
|
||||
[supported languages]: https://github.com/alecthomas/chroma#supported-languages
|
||||
[fzf]: https://github.com/junegunn/fzf
|
||||
[go]: https://golang.org
|
||||
|
||||
@@ -22,6 +22,8 @@ TESTS=(
|
||||
"FuzzTagged:./internal/sheet:tag matching with malicious input"
|
||||
"FuzzFilter:./internal/sheets:tag filtering operations"
|
||||
"FuzzTags:./internal/sheets:tag aggregation and sorting"
|
||||
"FuzzFindLocalCheatpath:./internal/config:recursive .cheat directory discovery"
|
||||
"FuzzFindLocalCheatpathNearestWins:./internal/config:nearest .cheat wins invariant"
|
||||
)
|
||||
|
||||
echo "Running fuzz tests ($DURATION each)..."
|
||||
|
||||
128
cmd/cheat/brief_integration_test.go
Normal file
128
cmd/cheat/brief_integration_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBriefFlagIntegration exercises the -b/--brief flag end-to-end.
|
||||
func TestBriefFlagIntegration(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("integration test uses Unix-specific env vars")
|
||||
}
|
||||
|
||||
// Build the cheat binary once for all sub-tests.
|
||||
binPath := filepath.Join(t.TempDir(), "cheat_test")
|
||||
build := exec.Command("go", "build", "-o", binPath, ".")
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Set up a temp environment with some cheatsheets.
|
||||
root := t.TempDir()
|
||||
sheetsDir := filepath.Join(root, "sheets")
|
||||
os.MkdirAll(sheetsDir, 0755)
|
||||
|
||||
os.WriteFile(
|
||||
filepath.Join(sheetsDir, "tar"),
|
||||
[]byte("---\nsyntax: bash\ntags: [ compression ]\n---\ntar xf archive.tar\n"),
|
||||
0644,
|
||||
)
|
||||
os.WriteFile(
|
||||
filepath.Join(sheetsDir, "curl"),
|
||||
[]byte("---\nsyntax: bash\ntags: [ networking, http ]\n---\ncurl https://example.com\n"),
|
||||
0644,
|
||||
)
|
||||
|
||||
confPath := filepath.Join(root, "conf.yml")
|
||||
conf := fmt.Sprintf("---\neditor: vi\ncolorize: false\ncheatpaths:\n - name: test\n path: %s\n readonly: true\n", sheetsDir)
|
||||
os.WriteFile(confPath, []byte(conf), 0644)
|
||||
|
||||
env := []string{
|
||||
"CHEAT_CONFIG_PATH=" + confPath,
|
||||
"HOME=" + root,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"EDITOR=vi",
|
||||
}
|
||||
|
||||
run := func(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command(binPath, args...)
|
||||
cmd.Dir = root
|
||||
cmd.Env = env
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat %v failed: %v\nOutput: %s", args, err, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
t.Run("brief output omits file path column", func(t *testing.T) {
|
||||
output := run(t, "-b")
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
|
||||
// Header should have title and tags but not file
|
||||
if !strings.Contains(lines[0], "title:") {
|
||||
t.Errorf("expected title: in header, got: %s", lines[0])
|
||||
}
|
||||
if !strings.Contains(lines[0], "tags:") {
|
||||
t.Errorf("expected tags: in header, got: %s", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[0], "file:") {
|
||||
t.Errorf("brief output should not contain file: column, got: %s", lines[0])
|
||||
}
|
||||
|
||||
// Data lines should not contain the sheets directory path
|
||||
for _, line := range lines[1:] {
|
||||
if strings.Contains(line, sheetsDir) {
|
||||
t.Errorf("brief output should not contain file paths, got: %s", line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list output still includes file path column", func(t *testing.T) {
|
||||
output := run(t, "-l")
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
|
||||
if !strings.Contains(lines[0], "file:") {
|
||||
t.Errorf("list output should contain file: column, got: %s", lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("brief with filter works", func(t *testing.T) {
|
||||
output := run(t, "-b", "tar")
|
||||
if !strings.Contains(output, "tar") {
|
||||
t.Errorf("expected tar in output, got: %s", output)
|
||||
}
|
||||
if strings.Contains(output, "curl") {
|
||||
t.Errorf("filter should exclude curl, got: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined -lb works identically to -b", func(t *testing.T) {
|
||||
briefOnly := run(t, "-b", "tar")
|
||||
combined := run(t, "-lb", "tar")
|
||||
if briefOnly != combined {
|
||||
t.Errorf("-b and -lb should produce identical output\n-b:\n%s\n-lb:\n%s", briefOnly, combined)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("brief with tag filter works", func(t *testing.T) {
|
||||
output := run(t, "-b", "-t", "networking")
|
||||
if !strings.Contains(output, "curl") {
|
||||
t.Errorf("expected curl in tag-filtered output, got: %s", output)
|
||||
}
|
||||
if strings.Contains(output, "tar") {
|
||||
// tar is tagged "compression", not "networking"
|
||||
t.Errorf("tag filter should exclude tar, got: %s", output)
|
||||
}
|
||||
if strings.Contains(output, "file:") {
|
||||
t.Errorf("brief output should not contain file: column, got: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
245
cmd/cheat/cheatpath_integration_test.go
Normal file
245
cmd/cheat/cheatpath_integration_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// hasCwdCheatpath checks whether the --directories output contains a
|
||||
// cheatpath named "cwd". The output format is "name: path\n" per line
|
||||
// (tabwriter-aligned), so we look for a line beginning with "cwd".
|
||||
func hasCwdCheatpath(output string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if strings.HasPrefix(line, "cwd") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestLocalCheatpathIntegration exercises the recursive .cheat directory
|
||||
// discovery end-to-end: it builds the real cheat binary, sets up filesystem
|
||||
// layouts, and verifies behaviour from the user's perspective.
|
||||
func TestLocalCheatpathIntegration(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("integration test uses Unix-specific env vars")
|
||||
}
|
||||
|
||||
// Build the cheat binary once for all sub-tests.
|
||||
binPath := filepath.Join(t.TempDir(), "cheat_test")
|
||||
build := exec.Command("go", "build", "-o", binPath, ".")
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// cheatEnv returns a minimal environment for the cheat binary.
|
||||
cheatEnv := func(confPath, home string) []string {
|
||||
return []string{
|
||||
"CHEAT_CONFIG_PATH=" + confPath,
|
||||
"HOME=" + home,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"EDITOR=vi",
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfig writes a minimal valid config file referencing sheetsDir.
|
||||
writeConfig := func(t *testing.T, dir, sheetsDir string) string {
|
||||
t.Helper()
|
||||
conf := fmt.Sprintf("---\neditor: vi\ncolorize: false\ncheatpaths:\n - name: base\n path: %s\n readonly: true\n", sheetsDir)
|
||||
confPath := filepath.Join(dir, "conf.yml")
|
||||
if err := os.WriteFile(confPath, []byte(conf), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
return confPath
|
||||
}
|
||||
|
||||
t.Run("parent .cheat is discovered from subdirectory", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// Configured cheatpath (empty but must exist for validation)
|
||||
sheetsDir := filepath.Join(root, "sheets")
|
||||
os.MkdirAll(sheetsDir, 0755)
|
||||
|
||||
// .cheat at root with a cheatsheet
|
||||
dotCheat := filepath.Join(root, ".cheat")
|
||||
os.Mkdir(dotCheat, 0755)
|
||||
os.WriteFile(
|
||||
filepath.Join(dotCheat, "localsheet"),
|
||||
[]byte("---\nsyntax: bash\n---\necho hello from local\n"),
|
||||
0644,
|
||||
)
|
||||
|
||||
confPath := writeConfig(t, root, sheetsDir)
|
||||
|
||||
// Work from a subdirectory
|
||||
workDir := filepath.Join(root, "src", "pkg")
|
||||
os.MkdirAll(workDir, 0755)
|
||||
env := cheatEnv(confPath, root)
|
||||
|
||||
// --directories should list "cwd" cheatpath
|
||||
cmd := exec.Command(binPath, "--directories")
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = env
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
if !hasCwdCheatpath(string(output)) {
|
||||
t.Errorf("expected 'cwd' cheatpath in --directories output:\n%s", output)
|
||||
}
|
||||
|
||||
// Viewing the cheatsheet should show its content
|
||||
cmd2 := exec.Command(binPath, "localsheet")
|
||||
cmd2.Dir = workDir
|
||||
cmd2.Env = env
|
||||
output2, err := cmd2.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat localsheet failed: %v\nOutput: %s", err, output2)
|
||||
}
|
||||
if !strings.Contains(string(output2), "echo hello from local") {
|
||||
t.Errorf("expected cheatsheet content, got:\n%s", output2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("grandparent .cheat is discovered from deep subdirectory", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
sheetsDir := filepath.Join(root, "sheets")
|
||||
os.MkdirAll(sheetsDir, 0755)
|
||||
|
||||
dotCheat := filepath.Join(root, ".cheat")
|
||||
os.Mkdir(dotCheat, 0755)
|
||||
os.WriteFile(
|
||||
filepath.Join(dotCheat, "deepsheet"),
|
||||
[]byte("---\nsyntax: bash\n---\ndeep discovery works\n"),
|
||||
0644,
|
||||
)
|
||||
|
||||
confPath := writeConfig(t, root, sheetsDir)
|
||||
|
||||
deepDir := filepath.Join(root, "a", "b", "c", "d", "e")
|
||||
os.MkdirAll(deepDir, 0755)
|
||||
|
||||
cmd := exec.Command(binPath, "deepsheet")
|
||||
cmd.Dir = deepDir
|
||||
cmd.Env = cheatEnv(confPath, root)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat deepsheet failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "deep discovery works") {
|
||||
t.Errorf("expected cheatsheet content, got:\n%s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nearest .cheat wins over ancestor .cheat", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
sheetsDir := filepath.Join(root, "sheets")
|
||||
os.MkdirAll(sheetsDir, 0755)
|
||||
|
||||
// .cheat at root
|
||||
rootCheat := filepath.Join(root, ".cheat")
|
||||
os.Mkdir(rootCheat, 0755)
|
||||
os.WriteFile(
|
||||
filepath.Join(rootCheat, "shared"),
|
||||
[]byte("---\nsyntax: bash\n---\nfrom root\n"),
|
||||
0644,
|
||||
)
|
||||
|
||||
// .cheat at project/ (nearer)
|
||||
projectDir := filepath.Join(root, "project")
|
||||
os.MkdirAll(projectDir, 0755)
|
||||
projectCheat := filepath.Join(projectDir, ".cheat")
|
||||
os.Mkdir(projectCheat, 0755)
|
||||
os.WriteFile(
|
||||
filepath.Join(projectCheat, "shared"),
|
||||
[]byte("---\nsyntax: bash\n---\nfrom project nearest\n"),
|
||||
0644,
|
||||
)
|
||||
|
||||
confPath := writeConfig(t, root, sheetsDir)
|
||||
|
||||
workDir := filepath.Join(projectDir, "src")
|
||||
os.MkdirAll(workDir, 0755)
|
||||
env := cheatEnv(confPath, root)
|
||||
|
||||
// --directories should list the nearer cheatpath
|
||||
cmd := exec.Command(binPath, "--directories")
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = env
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), projectCheat) {
|
||||
t.Errorf("expected project .cheat path in output, got:\n%s", output)
|
||||
}
|
||||
|
||||
// "shared" sheet should come from the nearer .cheat
|
||||
cmd2 := exec.Command(binPath, "shared")
|
||||
cmd2.Dir = workDir
|
||||
cmd2.Env = env
|
||||
output2, err := cmd2.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat shared failed: %v\nOutput: %s", err, output2)
|
||||
}
|
||||
if !strings.Contains(string(output2), "from project nearest") {
|
||||
t.Errorf("expected nearest .cheat content, got:\n%s", output2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no .cheat directory means no cwd cheatpath", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
sheetsDir := filepath.Join(root, "sheets")
|
||||
os.MkdirAll(sheetsDir, 0755)
|
||||
// Need at least one sheet for --directories to work without error
|
||||
os.WriteFile(filepath.Join(sheetsDir, "placeholder"),
|
||||
[]byte("---\nsyntax: bash\n---\nplaceholder\n"), 0644)
|
||||
|
||||
confPath := writeConfig(t, root, sheetsDir)
|
||||
|
||||
// No .cheat anywhere under root
|
||||
cmd := exec.Command(binPath, "--directories")
|
||||
cmd.Dir = root
|
||||
cmd.Env = cheatEnv(confPath, root)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
if hasCwdCheatpath(string(output)) {
|
||||
t.Errorf("'cwd' cheatpath should not appear when no .cheat exists:\n%s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(".cheat file (not directory) is ignored", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
sheetsDir := filepath.Join(root, "sheets")
|
||||
os.MkdirAll(sheetsDir, 0755)
|
||||
os.WriteFile(filepath.Join(sheetsDir, "placeholder"),
|
||||
[]byte("---\nsyntax: bash\n---\nplaceholder\n"), 0644)
|
||||
|
||||
// Create .cheat as a regular file
|
||||
os.WriteFile(filepath.Join(root, ".cheat"), []byte("not a dir"), 0644)
|
||||
|
||||
confPath := writeConfig(t, root, sheetsDir)
|
||||
|
||||
cmd := exec.Command(binPath, "--directories")
|
||||
cmd.Dir = root
|
||||
cmd.Env = cheatEnv(confPath, root)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
if hasCwdCheatpath(string(output)) {
|
||||
t.Errorf("'cwd' should not appear for a .cheat file:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -87,13 +87,18 @@ func cmdList(opts map[string]interface{}, 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
|
||||
if opts["--brief"].(bool) {
|
||||
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, ","))
|
||||
}
|
||||
}
|
||||
|
||||
// write columnized output to stdout
|
||||
w.Flush()
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
// configs returns the default configuration template
|
||||
func configs() string {
|
||||
return `---
|
||||
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL.
|
||||
# The editor to use with 'cheat -e <sheet>'. Overridden by $VISUAL or $EDITOR.
|
||||
editor: EDITOR_PATH
|
||||
|
||||
# Should 'cheat' always colorize output?
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/cheat/cheat/internal/installer"
|
||||
)
|
||||
|
||||
const version = "4.5.1"
|
||||
const version = "4.7.0"
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -129,7 +129,7 @@ func main() {
|
||||
case opts["--edit"] != nil:
|
||||
cmd = cmdEdit
|
||||
|
||||
case opts["--list"].(bool):
|
||||
case opts["--list"].(bool), opts["--brief"].(bool):
|
||||
cmd = cmdList
|
||||
|
||||
case opts["--tags"].(bool):
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
@@ -108,23 +107,15 @@ cheatpaths:
|
||||
cmd := exec.Command(cheatBin, tc.args...)
|
||||
cmd.Env = env
|
||||
|
||||
// Capture output to prevent spamming
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
start := time.Now()
|
||||
err := cmd.Run()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
b.Fatalf("Command failed: %v\nStderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// Report custom metric
|
||||
b.ReportMetric(float64(elapsed.Nanoseconds())/1e6, "ms/op")
|
||||
|
||||
// Ensure we got some results
|
||||
if stdout.Len() == 0 {
|
||||
b.Fatal("No output from search")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ func usage() string {
|
||||
Options:
|
||||
--init Write a default config file to stdout
|
||||
-a --all Search among all cheatpaths
|
||||
-b --brief List cheatsheets without file paths
|
||||
-c --colorize Colorize output
|
||||
-d --directories List cheatsheet directories
|
||||
-e --edit=<cheatsheet> Edit <cheatsheet>
|
||||
@@ -41,8 +42,8 @@ Examples:
|
||||
To list all available cheatsheets:
|
||||
cheat -l
|
||||
|
||||
To list all cheatsheets whose titles match "apt":
|
||||
cheat -l apt
|
||||
To briefly list all cheatsheets whose titles match "apt":
|
||||
cheat -b apt
|
||||
|
||||
To list all tags in use:
|
||||
cheat -T
|
||||
|
||||
80
doc/adr/004-recursive-cheat-directory-search.md
Normal file
80
doc/adr/004-recursive-cheat-directory-search.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 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/config.go`
|
||||
@@ -1,38 +0,0 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
This directory contains Architecture Decision Records (ADRs) for the cheat project.
|
||||
|
||||
## What is an ADR?
|
||||
|
||||
An Architecture Decision Record captures an important architectural decision made along with its context and consequences. ADRs help us:
|
||||
|
||||
- Document why decisions were made
|
||||
- Understand the context and trade-offs
|
||||
- Review decisions when requirements change
|
||||
- Onboard new contributors
|
||||
|
||||
## ADR Format
|
||||
|
||||
Each ADR follows this template:
|
||||
|
||||
1. **Title**: ADR-NNN: Brief description
|
||||
2. **Date**: When the decision was made
|
||||
3. **Status**: Proposed, Accepted, Deprecated, Superseded
|
||||
4. **Context**: What prompted this decision?
|
||||
5. **Decision**: What did we decide to do?
|
||||
6. **Consequences**: What are the positive, negative, and neutral outcomes?
|
||||
|
||||
## Index of ADRs
|
||||
|
||||
| ADR | Title | Status | Date |
|
||||
|-----|-------|--------|------|
|
||||
| [001](001-path-traversal-protection.md) | Path Traversal Protection for Cheatsheet Names | Accepted | 2025-01-21 |
|
||||
| [002](002-environment-variable-parsing.md) | No Defensive Checks for Environment Variable Parsing | Accepted | 2025-01-21 |
|
||||
| [003](003-search-parallelization.md) | No Parallelization for Search Operations | Accepted | 2025-01-22 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
1. Copy the template from an existing ADR
|
||||
2. Use the next sequential number
|
||||
3. Fill in all sections
|
||||
4. Include the ADR alongside the commit implementing the decision
|
||||
@@ -23,6 +23,9 @@ Display the config file path.
|
||||
\-a, \[en]all
|
||||
Search among all cheatpaths.
|
||||
.TP
|
||||
\-b, \[en]brief
|
||||
List cheatsheets without file paths.
|
||||
.TP
|
||||
\-c, \[en]colorize
|
||||
Colorize output.
|
||||
.TP
|
||||
@@ -72,8 +75,8 @@ cheat \-d
|
||||
To list all available cheatsheets:
|
||||
cheat \-l
|
||||
.TP
|
||||
To list all cheatsheets whose titles match `apt':
|
||||
cheat \-l \f[I]apt\f[R]
|
||||
To briefly list all cheatsheets whose titles match `apt':
|
||||
cheat \-b \f[I]apt\f[R]
|
||||
.TP
|
||||
To list all tags in use:
|
||||
cheat \-T
|
||||
|
||||
@@ -29,6 +29,9 @@ OPTIONS
|
||||
-a, --all
|
||||
: Search among all cheatpaths.
|
||||
|
||||
-b, --brief
|
||||
: List cheatsheets without file paths.
|
||||
|
||||
-c, --colorize
|
||||
: Colorize output.
|
||||
|
||||
@@ -81,8 +84,8 @@ To view all cheatsheet directories:
|
||||
To list all available cheatsheets:
|
||||
: cheat -l
|
||||
|
||||
To list all cheatsheets whose titles match 'apt':
|
||||
: cheat -l _apt_
|
||||
To briefly list all cheatsheets whose titles match 'apt':
|
||||
: cheat -b _apt_
|
||||
|
||||
To list all tags in use:
|
||||
: cheat -T
|
||||
|
||||
@@ -88,26 +88,3 @@ func TestCheatpathValidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheatpathStruct(t *testing.T) {
|
||||
// Test that the struct fields work as expected
|
||||
cp := Cheatpath{
|
||||
Name: "test",
|
||||
Path: "/test/path",
|
||||
ReadOnly: true,
|
||||
Tags: []string{"tag1", "tag2"},
|
||||
}
|
||||
|
||||
if cp.Name != "test" {
|
||||
t.Errorf("expected Name to be 'test', got %q", cp.Name)
|
||||
}
|
||||
if cp.Path != "/test/path" {
|
||||
t.Errorf("expected Path to be '/test/path', got %q", cp.Path)
|
||||
}
|
||||
if !cp.ReadOnly {
|
||||
t.Error("expected ReadOnly to be true")
|
||||
}
|
||||
if len(cp.Tags) != 2 || cp.Tags[0] != "tag1" || cp.Tags[1] != "tag2" {
|
||||
t.Errorf("expected Tags to be [tag1 tag2], got %v", cp.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@
|
||||
// # Directory-Scoped Cheatpaths
|
||||
//
|
||||
// The package supports directory-scoped cheatpaths via `.cheat` directories.
|
||||
// When running cheat from a directory containing a `.cheat` subdirectory,
|
||||
// that directory is temporarily added to the available cheatpaths.
|
||||
// When running cheat, the tool walks upward from the current working directory
|
||||
// to the filesystem root, stopping at the first `.cheat` directory found. That
|
||||
// directory is temporarily added to the available cheatpaths.
|
||||
//
|
||||
// # Precedence and Overrides
|
||||
//
|
||||
|
||||
@@ -45,21 +45,20 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
|
||||
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
||||
}
|
||||
|
||||
// if a .cheat directory exists locally, append it to the cheatpaths
|
||||
// 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)
|
||||
}
|
||||
|
||||
local := filepath.Join(cwd, ".cheat")
|
||||
if _, err := os.Stat(local); err == nil {
|
||||
if local := findLocalCheatpath(cwd); local != "" {
|
||||
path := cp.Cheatpath{
|
||||
Name: "cwd",
|
||||
Path: local,
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
conf.Cheatpaths = append(conf.Cheatpaths, path)
|
||||
}
|
||||
|
||||
@@ -107,10 +106,17 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
|
||||
}
|
||||
conf.Cheatpaths = validPaths
|
||||
|
||||
// trim editor whitespace
|
||||
// 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 not provided in the configs, attempt to choose one
|
||||
// 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 {
|
||||
@@ -133,3 +139,21 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/mock"
|
||||
@@ -19,7 +18,7 @@ func TestConfigYAMLErrors(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
invalidYAML := filepath.Join(tempDir, "invalid.yml")
|
||||
err = os.WriteFile(invalidYAML, []byte("invalid: yaml: content:\n - no closing"), 0644)
|
||||
err = os.WriteFile(invalidYAML, []byte("cheatpaths: [{unclosed\n"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write invalid yaml: %v", err)
|
||||
}
|
||||
@@ -31,61 +30,6 @@ func TestConfigYAMLErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigLocalCheatpath tests local .cheat directory detection
|
||||
func TestConfigLocalCheatpath(t *testing.T) {
|
||||
// Create a temporary directory to act as working directory
|
||||
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)
|
||||
}
|
||||
|
||||
// Save current working directory
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
// Change to temp directory
|
||||
err = os.Chdir(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to change dir: %v", err)
|
||||
}
|
||||
|
||||
// Create .cheat directory
|
||||
localCheat := filepath.Join(tempDir, ".cheat")
|
||||
err = os.Mkdir(localCheat, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||
}
|
||||
|
||||
// Load config
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Check that local cheatpath was added
|
||||
found := false
|
||||
for _, cp := range conf.Cheatpaths {
|
||||
if cp.Name == "cwd" && cp.Path == localCheat {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("local .cheat directory was not added to cheatpaths")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigDefaults tests default values
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
// Load empty config
|
||||
@@ -154,7 +98,10 @@ cheatpaths:
|
||||
}
|
||||
|
||||
// Verify symlink was resolved
|
||||
if len(conf.Cheatpaths) > 0 && conf.Cheatpaths[0].Path != targetDir {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -199,70 +146,3 @@ cheatpaths:
|
||||
t.Errorf("expected broken cheatpath to be filtered out, got %d cheatpaths", len(conf.Cheatpaths))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigTildeExpansionError tests tilde expansion error handling
|
||||
func TestConfigTildeExpansionError(t *testing.T) {
|
||||
// This is tricky to test without mocking homedir.Expand
|
||||
// We'll create a config with an invalid home reference
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create config with user that likely doesn't exist
|
||||
configContent := `---
|
||||
editor: vim
|
||||
cheatpaths:
|
||||
- name: test
|
||||
path: ~nonexistentuser12345/cheat
|
||||
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 - this may or may not fail depending on the system
|
||||
// but we're testing that it doesn't panic
|
||||
_, _ = New(map[string]interface{}{}, configFile, false)
|
||||
}
|
||||
|
||||
// TestConfigGetCwdError tests error handling when os.Getwd fails
|
||||
func TestConfigGetCwdError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not allow removing the current directory")
|
||||
}
|
||||
|
||||
// This is difficult to test without being able to break os.Getwd
|
||||
// We'll create a scenario where the current directory is removed
|
||||
|
||||
// Create and enter a temp directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
err = os.Chdir(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to change dir: %v", err)
|
||||
}
|
||||
|
||||
// Remove the directory we're in
|
||||
err = os.RemoveAll(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove temp dir: %v", err)
|
||||
}
|
||||
|
||||
// Now os.Getwd should fail
|
||||
_, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
// This might not fail on all systems, so we just ensure no panic
|
||||
_ = err
|
||||
}
|
||||
|
||||
67
internal/config/config_fuzz_test.go
Normal file
67
internal/config/config_fuzz_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -14,9 +14,277 @@ import (
|
||||
"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(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
|
||||
if err != nil {
|
||||
@@ -76,40 +344,78 @@ func TestConfigFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyEditor asserts that envvars are respected if an editor is not
|
||||
// specified in the configs
|
||||
func TestEmptyEditor(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Editor() returns notepad on Windows before checking env vars")
|
||||
// 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)
|
||||
}()
|
||||
|
||||
// with no env vars, the config file value should be used
|
||||
os.Unsetenv("VISUAL")
|
||||
os.Unsetenv("EDITOR")
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.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)
|
||||
}
|
||||
|
||||
// clear the environment variables
|
||||
os.Setenv("VISUAL", "")
|
||||
os.Setenv("EDITOR", "")
|
||||
// $EDITOR should override the config file value
|
||||
os.Setenv("EDITOR", "nano")
|
||||
conf, err = New(map[string]interface{}{}, mock.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)
|
||||
}
|
||||
|
||||
// initialize a config
|
||||
// $VISUAL should override both $EDITOR and the config file value
|
||||
os.Setenv("VISUAL", "emacs")
|
||||
conf, err = New(map[string]interface{}{}, mock.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")
|
||||
os.Setenv("EDITOR", "foo")
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to initialize test: %v", err)
|
||||
}
|
||||
|
||||
// set editor, and assert that it is respected
|
||||
os.Setenv("EDITOR", "foo")
|
||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to init configs: %v", err)
|
||||
t.Fatalf("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 that it overrides editor
|
||||
// set $VISUAL and assert it takes precedence over $EDITOR
|
||||
os.Setenv("VISUAL", "bar")
|
||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to init configs: %v", err)
|
||||
t.Fatalf("failed to init configs: %v", err)
|
||||
}
|
||||
if conf.Editor != "bar" {
|
||||
t.Errorf("failed to respect editor: want: bar, got: %s", conf.Editor)
|
||||
t.Errorf("failed to respect $VISUAL: want: bar, got: %s", conf.Editor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -90,9 +89,6 @@ func TestInitWriteError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error when writing to invalid path, got nil")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "failed to create") {
|
||||
t.Errorf("expected 'failed to create' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitExistingFile tests that Init overwrites existing files
|
||||
|
||||
@@ -7,6 +7,16 @@ import (
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
@@ -44,29 +45,20 @@ func TestPager(t *testing.T) {
|
||||
os.Setenv("PAGER", "")
|
||||
pager := Pager()
|
||||
|
||||
// Should find one of the fallback pagers or return empty string
|
||||
if pager == "" {
|
||||
return // no pager found is acceptable
|
||||
}
|
||||
|
||||
// Should find one of the known fallback pagers
|
||||
validPagers := map[string]bool{
|
||||
"": true, // no pager found
|
||||
"pager": true,
|
||||
"less": true,
|
||||
"more": true,
|
||||
}
|
||||
|
||||
// Check if it's a path to one of these
|
||||
found := false
|
||||
for p := range validPagers {
|
||||
if p == "" && pager == "" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if p != "" && (pager == p || len(pager) >= len(p) && pager[len(pager)-len(p):] == p) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("unexpected pager value: %s", pager)
|
||||
base := filepath.Base(pager)
|
||||
if !validPagers[base] {
|
||||
t.Errorf("unexpected pager value: %s (base: %s)", pager, base)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -71,19 +71,28 @@ func TestInvalidateMissingCheatpaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingInvalidFormatters asserts that configs which contain invalid
|
||||
// TestInvalidateInvalidFormatter asserts that configs which contain invalid
|
||||
// formatters are invalidated
|
||||
func TestMissingInvalidFormatters(t *testing.T) {
|
||||
func TestInvalidateInvalidFormatter(t *testing.T) {
|
||||
|
||||
// mock a config
|
||||
// mock a config with a valid editor and cheatpaths but invalid formatter
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "html",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
// assert that the config is invalidated due to the formatter
|
||||
if err := conf.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate config without formatter")
|
||||
t.Errorf("failed to invalidate config with invalid formatter")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package installer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -158,23 +157,3 @@ func TestPromptError(t *testing.T) {
|
||||
t.Errorf("expected 'failed to prompt' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromptIntegration provides a simple integration test
|
||||
func TestPromptIntegration(t *testing.T) {
|
||||
// This demonstrates how the prompt would be used in practice
|
||||
// It's skipped by default since it requires actual user input
|
||||
if os.Getenv("TEST_INTERACTIVE") != "1" {
|
||||
t.Skip("Skipping interactive test - set TEST_INTERACTIVE=1 to run")
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Interactive Prompt Test ===")
|
||||
fmt.Println("You will be prompted to answer a question.")
|
||||
fmt.Println("Try different inputs: y, n, Y, N, empty (just press Enter)")
|
||||
|
||||
result, err := Prompt("Would you like to continue? [Y/n]", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Prompt failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("You answered: %v\n", result)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -245,10 +244,10 @@ cheatpaths:
|
||||
if strings.Contains(contentStr, "PERSONAL_PATH") {
|
||||
t.Error("PERSONAL_PATH was not replaced")
|
||||
}
|
||||
if strings.Contains(contentStr, "EDITOR_PATH") && !strings.Contains(contentStr, fmt.Sprintf("editor: %s", "")) {
|
||||
if strings.Contains(contentStr, "EDITOR_PATH") {
|
||||
t.Error("EDITOR_PATH was not replaced")
|
||||
}
|
||||
if strings.Contains(contentStr, "PAGER_PATH") && !strings.Contains(contentStr, fmt.Sprintf("pager: %s", "")) {
|
||||
if strings.Contains(contentStr, "PAGER_PATH") {
|
||||
t.Error("PAGER_PATH was not replaced")
|
||||
}
|
||||
if strings.Contains(contentStr, "WORK_PATH") {
|
||||
|
||||
@@ -6,6 +6,11 @@ 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) {
|
||||
@@ -50,9 +55,20 @@ func GitDir(path string) (bool, error) {
|
||||
|
||||
See: https://github.com/cheat/cheat/issues/699
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
To summarize, this code must account for the following possibilities:
|
||||
|
||||
@@ -61,17 +77,16 @@ 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.
|
||||
|
||||
There is a lot of nuance to all of this, and it would be worthwhile to
|
||||
do two things to stop writing bugs here:
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
@@ -83,8 +98,8 @@ func GitDir(path string) (bool, error) {
|
||||
of syscalls should be significantly lower with the skip check enabled.
|
||||
*/
|
||||
|
||||
// determine if the literal string `.git` appears within `path`
|
||||
pos := strings.Index(path, fmt.Sprintf(".git%s", string(os.PathSeparator)))
|
||||
// determine if `.git` appears as a complete path component
|
||||
pos := strings.Index(path, gitSep)
|
||||
|
||||
// if it does not, we know for certain that we are not within a `.git`
|
||||
// directory.
|
||||
|
||||
@@ -1,137 +1,191 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGitDir(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
// 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()
|
||||
|
||||
// Create test directory structure
|
||||
testDirs := []string{
|
||||
filepath.Join(tempDir, ".git"),
|
||||
filepath.Join(tempDir, ".git", "objects"),
|
||||
filepath.Join(tempDir, ".git", "refs"),
|
||||
filepath.Join(tempDir, "regular"),
|
||||
filepath.Join(tempDir, "regular", ".git"),
|
||||
filepath.Join(tempDir, "submodule"),
|
||||
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 testDirs {
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("failed to create dir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create test files
|
||||
testFiles := map[string]string{
|
||||
filepath.Join(tempDir, ".gitignore"): "*.tmp\n",
|
||||
filepath.Join(tempDir, ".gitattributes"): "* text=auto\n",
|
||||
filepath.Join(tempDir, "submodule", ".git"): "gitdir: ../.git/modules/submodule\n",
|
||||
filepath.Join(tempDir, "regular", "sheet.txt"): "content\n",
|
||||
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 file, content := range testFiles {
|
||||
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to create file %s: %v", file, err)
|
||||
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
|
||||
wantErr bool
|
||||
}{
|
||||
// Case 1: not a repository — no .git anywhere in path
|
||||
{
|
||||
name: "not in git directory",
|
||||
path: filepath.Join(tempDir, "regular", "sheet.txt"),
|
||||
name: "plain directory, no repo",
|
||||
path: filepath.Join(root, "plain", "sheet"),
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Case 2: a repository — paths *inside* .git/ should be detected
|
||||
{
|
||||
name: "in .git directory",
|
||||
path: filepath.Join(tempDir, ".git", "objects", "file"),
|
||||
name: "inside .git directory",
|
||||
path: filepath.Join(root, "repo", ".git", "HEAD"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "in .git/refs directory",
|
||||
path: filepath.Join(tempDir, ".git", "refs", "heads", "main"),
|
||||
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(tempDir, ".gitignore"),
|
||||
path: filepath.Join(root, "repo", ".gitignore"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: ".gitattributes file",
|
||||
path: filepath.Join(tempDir, ".gitattributes"),
|
||||
path: filepath.Join(root, "repo", ".gitattributes"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "submodule with .git file",
|
||||
path: filepath.Join(tempDir, "submodule", "sheet.txt"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "path with .git in middle",
|
||||
path: filepath.Join(tempDir, "regular", ".git", "sheet.txt"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent path without .git",
|
||||
path: filepath.Join(tempDir, "nonexistent", "file"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GitDir(tt.path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GitDir() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("GitDir() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
},
|
||||
|
||||
func TestGitDirEdgeCases(t *testing.T) {
|
||||
// Test with paths that have .git but not as a directory separator
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Case 6: directory name ends with .git (#711)
|
||||
{
|
||||
name: "file ending with .git",
|
||||
path: "/tmp/myfile.git",
|
||||
name: "sheet under directory ending in .git",
|
||||
path: filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "directory ending with .git",
|
||||
path: "/tmp/myrepo.git",
|
||||
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: ".github directory",
|
||||
path: "/tmp/.github/workflows",
|
||||
name: "file inside .github directory",
|
||||
path: filepath.Join(root, ".github", "workflows", "ci.yml"),
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Hidden directory that is not .git
|
||||
{
|
||||
name: "legitimate.git-repo name",
|
||||
path: "/tmp/legitimate.git-repo/file",
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -140,8 +194,7 @@ func TestGitDirEdgeCases(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GitDir(tt.path)
|
||||
if err != nil {
|
||||
// It's ok if the path doesn't exist for these edge case tests
|
||||
return
|
||||
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)
|
||||
@@ -150,28 +203,153 @@ func TestGitDirEdgeCases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitDirPathSeparator(t *testing.T) {
|
||||
// Test that the function correctly uses os.PathSeparator
|
||||
// This is important for cross-platform compatibility
|
||||
// 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 a path with the wrong separator for the current OS
|
||||
var wrongSep string
|
||||
if os.PathSeparator == '/' {
|
||||
wrongSep = `\`
|
||||
} else {
|
||||
wrongSep = `/`
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Path with wrong separator should not be detected as git dir
|
||||
path := fmt.Sprintf("some%spath%s.git%sfile", wrongSep, wrongSep, wrongSep)
|
||||
isGit, err := GitDir(path)
|
||||
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 {
|
||||
// Path doesn't exist, which is fine
|
||||
return
|
||||
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)
|
||||
}
|
||||
|
||||
if isGit {
|
||||
t.Errorf("GitDir() incorrectly detected git dir with wrong path separator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/config"
|
||||
@@ -16,45 +17,26 @@ func TestColorize(t *testing.T) {
|
||||
}
|
||||
|
||||
// mock a sheet
|
||||
original := "echo 'foo'"
|
||||
s := Sheet{
|
||||
Text: "echo 'foo'",
|
||||
Text: original,
|
||||
}
|
||||
|
||||
// colorize the sheet text
|
||||
s.Colorize(conf)
|
||||
|
||||
// initialize expectations
|
||||
want := "[38;2;181;137;0mecho[0m[38;2;147;161;161m"
|
||||
want += " [0m[38;2;42;161;152m'foo'[0m"
|
||||
// assert that the text was modified (colorization applied)
|
||||
if s.Text == original {
|
||||
t.Error("Colorize did not modify sheet text")
|
||||
}
|
||||
|
||||
// assert
|
||||
if s.Text != want {
|
||||
t.Errorf("failed to colorize sheet: want: %s, got: %s", want, s.Text)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestColorizeError tests the error handling in Colorize
|
||||
func TestColorizeError(_ *testing.T) {
|
||||
// Create a sheet with content
|
||||
sheet := Sheet{
|
||||
Text: "some text",
|
||||
Syntax: "invalidlexer12345", // Use an invalid lexer that might cause issues
|
||||
}
|
||||
|
||||
// Create a config with invalid formatter/style
|
||||
conf := config.Config{
|
||||
Formatter: "invalidformatter",
|
||||
Style: "invalidstyle",
|
||||
}
|
||||
|
||||
// Store original text
|
||||
originalText := sheet.Text
|
||||
|
||||
// Colorize should not panic even with invalid settings
|
||||
sheet.Colorize(conf)
|
||||
|
||||
// The text might be unchanged if there was an error, or it might be colorized
|
||||
// We're mainly testing that it doesn't panic
|
||||
_ = sheet.Text
|
||||
_ = originalText
|
||||
}
|
||||
|
||||
@@ -12,13 +12,10 @@ func TestCopyErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func() (*Sheet, string, func())
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "source file does not exist",
|
||||
setup: func() (*Sheet, string, func()) {
|
||||
// Create a sheet with non-existent path
|
||||
sheet := &Sheet{
|
||||
Title: "test",
|
||||
Path: "/non/existent/file.txt",
|
||||
@@ -30,13 +27,10 @@ func TestCopyErrors(t *testing.T) {
|
||||
}
|
||||
return sheet, dest, cleanup
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "failed to open cheatsheet",
|
||||
},
|
||||
{
|
||||
name: "destination directory creation fails",
|
||||
setup: func() (*Sheet, string, func()) {
|
||||
// Create a source file
|
||||
src, err := os.CreateTemp("", "copy-test-src-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
@@ -50,13 +44,11 @@ func TestCopyErrors(t *testing.T) {
|
||||
CheatPath: "test",
|
||||
}
|
||||
|
||||
// Create a file where we want a directory
|
||||
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)
|
||||
}
|
||||
|
||||
// Try to create dest under the blocker file (will fail)
|
||||
dest := filepath.Join(blockerFile, "subdir", "dest.txt")
|
||||
|
||||
cleanup := func() {
|
||||
@@ -65,13 +57,10 @@ func TestCopyErrors(t *testing.T) {
|
||||
}
|
||||
return sheet, dest, cleanup
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "failed to create directory",
|
||||
},
|
||||
{
|
||||
name: "destination file creation fails",
|
||||
setup: func() (*Sheet, string, func()) {
|
||||
// Create a source file
|
||||
src, err := os.CreateTemp("", "copy-test-src-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
@@ -85,7 +74,6 @@ func TestCopyErrors(t *testing.T) {
|
||||
CheatPath: "test",
|
||||
}
|
||||
|
||||
// Create a directory where we want the file
|
||||
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)
|
||||
@@ -97,8 +85,6 @@ func TestCopyErrors(t *testing.T) {
|
||||
}
|
||||
return sheet, destDir, cleanup
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "failed to create outfile",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -108,43 +94,27 @@ func TestCopyErrors(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
err := sheet.Copy(dest)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Copy() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil && tt.errMsg != "" {
|
||||
if !contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("Copy() error = %v, want error containing %q", err, tt.errMsg)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("Copy() expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyIOError tests the io.Copy error case
|
||||
func TestCopyIOError(t *testing.T) {
|
||||
// This is difficult to test without mocking io.Copy
|
||||
// The error case would occur if the source file is modified
|
||||
// or removed after opening but before copying
|
||||
t.Skip("Skipping io.Copy error test - requires file system race condition")
|
||||
}
|
||||
|
||||
// TestCopyCleanupOnError verifies that partially written files are cleaned up on error
|
||||
func TestCopyCleanupOnError(t *testing.T) {
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Create a source file that we'll make unreadable after opening
|
||||
src, err := os.CreateTemp("", "copy-test-cleanup-*")
|
||||
src, err := os.CreateTemp("", "copy-test-unreadable-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(src.Name())
|
||||
|
||||
// Write some content
|
||||
content := "test content for cleanup"
|
||||
if _, err := src.WriteString(content); err != nil {
|
||||
if _, err := src.WriteString("test content"); err != nil {
|
||||
t.Fatalf("failed to write content: %v", err)
|
||||
}
|
||||
src.Close()
|
||||
@@ -155,38 +125,21 @@ func TestCopyCleanupOnError(t *testing.T) {
|
||||
CheatPath: "test",
|
||||
}
|
||||
|
||||
// Destination path
|
||||
dest := filepath.Join(os.TempDir(), "copy-cleanup-test.txt")
|
||||
defer os.Remove(dest) // Clean up if test fails
|
||||
dest := filepath.Join(os.TempDir(), "copy-unreadable-test.txt")
|
||||
defer os.Remove(dest)
|
||||
|
||||
// Make the source file unreadable (simulating a read error during copy)
|
||||
// This is platform-specific, but should work on Unix-like systems
|
||||
if err := os.Chmod(src.Name(), 0000); err != nil {
|
||||
t.Skip("Cannot change file permissions on this platform")
|
||||
}
|
||||
defer os.Chmod(src.Name(), 0644) // Restore permissions for cleanup
|
||||
defer os.Chmod(src.Name(), 0644)
|
||||
|
||||
// Attempt to copy - this should fail during io.Copy
|
||||
err = sheet.Copy(dest)
|
||||
if err == nil {
|
||||
t.Error("Expected Copy to fail with permission error")
|
||||
t.Error("expected Copy to fail with permission error")
|
||||
}
|
||||
|
||||
// Verify the destination file was cleaned up
|
||||
// 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 have been removed after copy failure")
|
||||
t.Error("destination file should not exist after open failure")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
|
||||
}
|
||||
|
||||
func containsHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -27,22 +27,3 @@ func TestParseWindowsLineEndings(t *testing.T) {
|
||||
t.Errorf("failed to parse syntax: want: %s, got: %s", want, fm.Syntax)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInvalidYAML tests parsing with invalid YAML in frontmatter
|
||||
func TestParseInvalidYAML(t *testing.T) {
|
||||
// stub our cheatsheet content with invalid YAML
|
||||
markdown := `---
|
||||
syntax: go
|
||||
tags: [ test
|
||||
unclosed bracket
|
||||
---
|
||||
To foo the bar: baz`
|
||||
|
||||
// parse the frontmatter
|
||||
_, _, err := parse(markdown)
|
||||
|
||||
// assert that an error was returned for invalid YAML
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 0, got: len %d", len(fm.Tags))
|
||||
t.Errorf("failed to parse tags: want: len 1, got: len %d", len(fm.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,69 +122,3 @@ func FuzzSearchRegex(f *testing.F) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzSearchCatastrophicBacktracking specifically tests for regex patterns
|
||||
// that could cause performance issues
|
||||
func FuzzSearchCatastrophicBacktracking(f *testing.F) {
|
||||
// Seed with patterns known to potentially cause issues
|
||||
f.Add("a", 10, 5)
|
||||
f.Add("x", 20, 3)
|
||||
|
||||
f.Fuzz(func(t *testing.T, char string, repeats int, groups int) {
|
||||
// Limit the size to avoid memory issues in the test
|
||||
if repeats > 30 || repeats < 0 || groups > 10 || groups < 0 || len(char) > 5 {
|
||||
t.Skip("Skipping invalid or overly large test case")
|
||||
}
|
||||
|
||||
// Construct patterns that might cause backtracking
|
||||
patterns := []string{
|
||||
strings.Repeat(char, repeats),
|
||||
"(" + char + "+)+",
|
||||
"(" + char + "*)*",
|
||||
"(" + char + "|" + char + ")*",
|
||||
}
|
||||
|
||||
// Add nested groups
|
||||
if groups > 0 && groups < 10 {
|
||||
nested := char
|
||||
for i := 0; i < groups; i++ {
|
||||
nested = "(" + nested + ")+"
|
||||
}
|
||||
patterns = append(patterns, nested)
|
||||
}
|
||||
|
||||
// Test text that might trigger backtracking
|
||||
testText := strings.Repeat(char, repeats) + "x"
|
||||
|
||||
for _, pattern := range patterns {
|
||||
// Try to compile the pattern
|
||||
reg, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// Invalid pattern, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// Test with timeout
|
||||
done := make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Search panicked with backtracking pattern %q: %v", pattern, r)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
sheet := Sheet{Text: testText}
|
||||
_ = sheet.Search(reg)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Completed successfully
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
t.Logf("Warning: potential backtracking issue with pattern %q (completed slowly)", pattern)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,28 +18,26 @@ func TestFilterSingleTag(t *testing.T) {
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
|
||||
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
|
||||
"bar": sheet.Sheet{Title: "bar", Tags: []string{"charlie"}},
|
||||
},
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
|
||||
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha"}},
|
||||
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
|
||||
},
|
||||
}
|
||||
|
||||
// filter the cheatsheets
|
||||
filtered := Filter(cheatpaths, []string{"bravo"})
|
||||
filtered := Filter(cheatpaths, []string{"alpha"})
|
||||
|
||||
// assert that the expect results were returned
|
||||
want := []map[string]sheet.Sheet{
|
||||
map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
|
||||
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
|
||||
},
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
|
||||
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
|
||||
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -127,64 +127,3 @@ func FuzzTags(f *testing.F) {
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzTagsStress tests Tags function with large numbers of tags
|
||||
func FuzzTagsStress(f *testing.F) {
|
||||
// Seed: number of unique tags, number of sheets, tags per sheet
|
||||
f.Add(10, 10, 5)
|
||||
f.Add(100, 50, 10)
|
||||
f.Add(1000, 100, 20)
|
||||
|
||||
f.Fuzz(func(t *testing.T, numUniqueTags int, numSheets int, tagsPerSheet int) {
|
||||
// Limit to reasonable values
|
||||
if numUniqueTags > 1000 || numUniqueTags < 0 ||
|
||||
numSheets > 1000 || numSheets < 0 ||
|
||||
tagsPerSheet > 100 || tagsPerSheet < 0 {
|
||||
t.Skip("Skipping unreasonable test case")
|
||||
}
|
||||
|
||||
// Generate unique tags
|
||||
uniqueTags := make([]string, numUniqueTags)
|
||||
for i := 0; i < numUniqueTags; i++ {
|
||||
uniqueTags[i] = "tag" + string(rune(i))
|
||||
}
|
||||
|
||||
// Create sheets with random tags
|
||||
cheatpaths := []map[string]sheet.Sheet{
|
||||
make(map[string]sheet.Sheet),
|
||||
}
|
||||
|
||||
for i := 0; i < numSheets; i++ {
|
||||
// Select random tags for this sheet
|
||||
sheetTags := make([]string, 0, tagsPerSheet)
|
||||
for j := 0; j < tagsPerSheet && j < numUniqueTags; j++ {
|
||||
// Distribute tags across sheets
|
||||
tagIndex := (i*tagsPerSheet + j) % numUniqueTags
|
||||
sheetTags = append(sheetTags, uniqueTags[tagIndex])
|
||||
}
|
||||
|
||||
cheatpaths[0]["sheet"+string(rune(i))] = sheet.Sheet{
|
||||
Title: "sheet" + string(rune(i)),
|
||||
Tags: sheetTags,
|
||||
}
|
||||
}
|
||||
|
||||
// Should handle large numbers efficiently
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Tags panicked with %d unique tags, %d sheets, %d tags/sheet: %v",
|
||||
numUniqueTags, numSheets, tagsPerSheet, r)
|
||||
}
|
||||
}()
|
||||
|
||||
result := Tags(cheatpaths)
|
||||
|
||||
// Should have at most numUniqueTags in result
|
||||
if len(result) > numUniqueTags {
|
||||
t.Errorf("More tags in result (%d) than unique tags created (%d)",
|
||||
len(result), numUniqueTags)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user