Compare commits

..

11 Commits

Author SHA1 Message Date
Christopher Allen Lane
f61203ac1b feat: add -b/--brief flag for compact list output (#505)
Add a --brief flag that lists cheatsheets with only title and tags,
omitting the file path column. Can be used standalone (-b) or combined
with --list (-lb), and composes with existing filters (-t, -p).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:21:35 -05:00
Christopher Allen Lane
f1db4ee378 Merge branch 'recursive-cheat-directory-search'
feat: walk up directory tree to find .cheat directory (#602)
2026-02-15 11:23:37 -05:00
Christopher Allen Lane
366d63afdc chore: bump version to 4.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:23:31 -05:00
Christopher Allen Lane
c1551683a3 feat: walk up directory tree to find .cheat directory (#602)
Previously cheat only checked the current working directory for a .cheat
subdirectory. Now it walks upward through ancestor directories, stopping
at the first .cheat directory found. This mirrors how git discovers .git
directories, so users can place .cheat at their project root and have it
work from any subdirectory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:30:29 -05:00
Christopher Allen Lane
09aad6f8ea docs: document supported syntax values in README
Adds a note explaining that syntax highlighting is powered by Chroma,
with a link to Chroma's supported languages list.

Closes #766

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:55:37 -05:00
Christopher Allen Lane
adb5a43810 chore: bump version to 4.5.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:35:59 -05:00
Christopher Allen Lane
cab039a9d8 docs: move ADRs to project root, remove boilerplate README
Move `doc/adr/` to `adr/` for discoverability. Remove the generic
ADR README — `ls adr/` serves the same purpose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:32:40 -05:00
Christopher Allen Lane
97e80beceb fix: match .git as complete path component, not suffix
Searching for `.git/` in file paths incorrectly matched directory names
ending with `.git` (e.g., `personal.git/cheat/hello`), causing sheets
under such paths to be silently skipped. Fix by requiring the path
separator on both sides (`/.git/`), so `.git` is only matched as a
complete path component.

Rewrites test suite with comprehensive coverage for all six documented
edge cases, including the #711 scenario and combination cases (e.g.,
a real .git directory inside a .git-suffixed parent).

Closes #711

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:32:35 -05:00
Christopher Allen Lane
1969423b5c fix: respect $VISUAL and $EDITOR env vars at runtime
Previously, env vars were only consulted during config generation
and baked into conf.yml. At runtime, the config file value was
always used, making it impossible to override the editor via
environment variables.

Now the precedence is: $VISUAL > $EDITOR > conf.yml > auto-detect.

Closes #589

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:54:42 -05:00
Christopher Allen Lane
4497ce1b84 ci: remove dead Homebrew formula bump workflow
This workflow has been failing for years due to an expired/missing
COMMITTER_TOKEN. Homebrew maintains their own automated version
bump pipeline, making this redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:43:01 -05:00
Christopher Allen Lane
5eee02bc40 build: produce static binaries with CGO_ENABLED=0
Eliminates glibc version mismatch errors when running release
binaries on systems with older glibc versions.

Closes #744

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:39:20 -05:00
25 changed files with 1495 additions and 233 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -70,6 +70,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
@@ -129,6 +135,10 @@ 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`.
@@ -168,9 +178,11 @@ editing.
### 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
--------------
@@ -189,5 +201,7 @@ 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

View File

@@ -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)..."

View 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)
}
})
}

View 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)
}
})
}

View File

@@ -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()

View File

@@ -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?

View File

@@ -15,7 +15,7 @@ import (
"github.com/cheat/cheat/internal/installer"
)
const version = "4.5.1"
const version = "4.6.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):

View File

@@ -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

View 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`

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
//

View File

@@ -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
}
}

View File

@@ -86,6 +86,187 @@ func TestConfigLocalCheatpath(t *testing.T) {
}
}
// TestConfigLocalCheatpathInParent tests that .cheat in a parent directory is found
func TestConfigLocalCheatpathInParent(t *testing.T) {
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)
}
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat in the root of the temp dir
localCheat := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(localCheat, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Create a subdirectory and cd into it
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
if err := os.Chdir(subDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
found := false
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" && cp.Path == localCheat {
found = true
break
}
}
if !found {
t.Error("parent .cheat directory was not added to cheatpaths")
}
}
// TestConfigLocalCheatpathNearestWins tests that the nearest .cheat wins
func TestConfigLocalCheatpathNearestWins(t *testing.T) {
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)
}
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat at root
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)
}
nearCheat := filepath.Join(subDir, ".cheat")
if err := os.Mkdir(nearCheat, 0755); err != nil {
t.Fatalf("failed to create near .cheat dir: %v", err)
}
// cd into sub/deep/
deepDir := filepath.Join(subDir, "deep")
if err := os.Mkdir(deepDir, 0755); err != nil {
t.Fatalf("failed to create deep dir: %v", err)
}
if err := os.Chdir(deepDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
found := false
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
if cp.Path != nearCheat {
t.Errorf("expected nearest .cheat %s, got %s", nearCheat, cp.Path)
}
found = true
break
}
}
if !found {
t.Error("no cwd cheatpath found")
}
}
// TestConfigNoLocalCheatpath tests that no cwd cheatpath is added when no .cheat exists
func TestConfigNoLocalCheatpath(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
t.Error("cwd cheatpath should not be added when no .cheat exists")
}
}
}
// TestConfigLocalCheatpathFileSkipped tests that a .cheat file (not dir) is skipped
func TestConfigLocalCheatpathFileSkipped(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat as a file, not a directory
if err := os.WriteFile(filepath.Join(tempDir, ".cheat"), []byte("not a dir"), 0644); err != nil {
t.Fatalf("failed to create .cheat file: %v", err)
}
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
t.Error("cwd cheatpath should not be added for a .cheat file")
}
}
}
// TestConfigDefaults tests default values
func TestConfigDefaults(t *testing.T) {
// Load empty config

View File

@@ -0,0 +1,122 @@
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)
}
})
}
// FuzzFindLocalCheatpathNearestWins verifies that when two .cheat directories
// exist at different levels of the ancestor chain, the nearest one is returned.
func FuzzFindLocalCheatpathNearestWins(f *testing.F) {
f.Add(uint8(5), uint8(1), uint8(3))
f.Add(uint8(8), uint8(0), uint8(7))
f.Add(uint8(3), uint8(0), uint8(2))
f.Add(uint8(10), uint8(2), uint8(8))
f.Fuzz(func(t *testing.T, totalDepth, shallowRaw, deepRaw uint8) {
depth := int(totalDepth%12) + 2 // 2..13 (need room for two placements)
s := int(shallowRaw) % depth
d := int(deepRaw) % depth
// Need two distinct levels
if s == d {
d = (d + 1) % depth
}
// Ensure s < d (shallow is higher in tree, deep is closer to search dir)
if s > d {
s, d = d, s
}
tempDir := t.TempDir()
// Build chain
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 both levels
shallowCheat := filepath.Join(dirs[s], ".cheat")
deepCheat := filepath.Join(dirs[d], ".cheat")
if err := os.Mkdir(shallowCheat, 0755); err != nil {
t.Fatalf("mkdir shallow .cheat: %v", err)
}
if err := os.Mkdir(deepCheat, 0755); err != nil {
t.Fatalf("mkdir deep .cheat: %v", err)
}
// Search from the deepest directory — should find the deeper (nearer) .cheat
result := findLocalCheatpath(current)
if result != deepCheat {
t.Errorf("depth=%d shallow=%d deep=%d: expected nearest %s, got %s",
depth, s, d, deepCheat, result)
}
})
}

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -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.

View File

@@ -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")
}
}