mirror of
https://github.com/cheat/cheat.git
synced 2026-03-07 11:13:33 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }}
|
|
||||||
1
Makefile
1
Makefile
@@ -27,6 +27,7 @@ ZIP := zip -m
|
|||||||
docker_image := cheat-devel:latest
|
docker_image := cheat-devel:latest
|
||||||
|
|
||||||
# build flags
|
# build flags
|
||||||
|
export CGO_ENABLED := 0
|
||||||
BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath
|
BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath
|
||||||
GOBIN :=
|
GOBIN :=
|
||||||
TMPDIR := /tmp
|
TMPDIR := /tmp
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
// configs returns the default configuration template
|
// configs returns the default configuration template
|
||||||
func configs() string {
|
func configs() string {
|
||||||
return `---
|
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
|
editor: EDITOR_PATH
|
||||||
|
|
||||||
# Should 'cheat' always colorize output?
|
# Should 'cheat' always colorize output?
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -107,10 +107,17 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
|
|||||||
}
|
}
|
||||||
conf.Cheatpaths = validPaths
|
conf.Cheatpaths = validPaths
|
||||||
|
|
||||||
// trim editor whitespace
|
// determine the editor: env vars override the config file value,
|
||||||
conf.Editor = strings.TrimSpace(conf.Editor)
|
// 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
|
// that's appropriate for the environment
|
||||||
if conf.Editor == "" {
|
if conf.Editor == "" {
|
||||||
if conf.Editor, err = Editor(); err != nil {
|
if conf.Editor, err = Editor(); err != nil {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
@@ -17,6 +16,16 @@ import (
|
|||||||
// TestConfig asserts that the configs are loaded correctly
|
// TestConfig asserts that the configs are loaded correctly
|
||||||
func TestConfigSuccessful(t *testing.T) {
|
func TestConfigSuccessful(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)
|
||||||
|
}()
|
||||||
|
|
||||||
// initialize a config
|
// initialize a config
|
||||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
|
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,40 +85,78 @@ func TestConfigFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEmptyEditor asserts that envvars are respected if an editor is not
|
// TestEditorEnvOverride asserts that $VISUAL and $EDITOR override the
|
||||||
// specified in the configs
|
// config file value at runtime (regression test for #589)
|
||||||
func TestEmptyEditor(t *testing.T) {
|
func TestEditorEnvOverride(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
// save and clear the environment variables
|
||||||
t.Skip("Editor() returns notepad on Windows before checking env vars")
|
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
|
// $EDITOR should override the config file value
|
||||||
os.Setenv("VISUAL", "")
|
os.Setenv("EDITOR", "nano")
|
||||||
os.Setenv("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 != "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)
|
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to initialize test: %v", err)
|
t.Fatalf("failed to init configs: %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)
|
|
||||||
}
|
}
|
||||||
if conf.Editor != "foo" {
|
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")
|
os.Setenv("VISUAL", "bar")
|
||||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to init configs: %v", err)
|
t.Fatalf("failed to init configs: %v", err)
|
||||||
}
|
}
|
||||||
if conf.Editor != "bar" {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewTrimsWhitespace(t *testing.T) {
|
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
|
// Create a temporary config file with whitespace in editor and pager
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import (
|
|||||||
"strings"
|
"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
|
// GitDir returns `true` if we are iterating over a directory contained within
|
||||||
// a repositories `.git` directory.
|
// a repositories `.git` directory.
|
||||||
func GitDir(path string) (bool, error) {
|
func GitDir(path string) (bool, error) {
|
||||||
@@ -50,9 +55,20 @@ func GitDir(path string) (bool, error) {
|
|||||||
|
|
||||||
See: https://github.com/cheat/cheat/issues/699
|
See: https://github.com/cheat/cheat/issues/699
|
||||||
|
|
||||||
Accounting for all of the above (hopefully?), the current solution is
|
Accounting for all of the above, the next solution was to search not
|
||||||
not to search for `.git`, but `.git/` (including the directory
|
for `.git`, but `.git/` (including the directory separator), and then
|
||||||
separator), and then only ceasing to walk the directory on a match.
|
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:
|
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
|
3. A cheatpath is a repository, and contains a `.git*` file
|
||||||
4. A cheatpath is a submodule
|
4. A cheatpath is a submodule
|
||||||
5. A cheatpath is a hidden directory
|
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
|
Care must be taken to support the above on both Unix and Windows
|
||||||
systems, which have different directory separators and line-endings.
|
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
|
NB: `filepath.Walk` always passes absolute paths to the walk function,
|
||||||
do two things to stop writing bugs here:
|
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
|
A reasonable smoke-test for ensuring that skipping is being applied
|
||||||
2. Discard string-matching solutions entirely, and use `go-git` instead
|
|
||||||
|
|
||||||
NB: A reasonable smoke-test for ensuring that skipping is being applied
|
|
||||||
correctly is to run the following command:
|
correctly is to run the following command:
|
||||||
|
|
||||||
make && strace ./dist/cheat -l | wc -l
|
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.
|
of syscalls should be significantly lower with the skip check enabled.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// determine if the literal string `.git` appears within `path`
|
// determine if `.git` appears as a complete path component
|
||||||
pos := strings.Index(path, fmt.Sprintf(".git%s", string(os.PathSeparator)))
|
pos := strings.Index(path, gitSep)
|
||||||
|
|
||||||
// if it does not, we know for certain that we are not within a `.git`
|
// if it does not, we know for certain that we are not within a `.git`
|
||||||
// directory.
|
// directory.
|
||||||
|
|||||||
@@ -1,137 +1,191 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGitDir(t *testing.T) {
|
// setupGitDirTestTree creates a temporary directory structure that exercises
|
||||||
// Create a temporary directory for testing
|
// every case documented in GitDir's comment block. The caller must defer
|
||||||
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
// os.RemoveAll on the returned root.
|
||||||
if err != nil {
|
//
|
||||||
t.Fatalf("failed to create temp dir: %v", err)
|
// Layout:
|
||||||
}
|
//
|
||||||
defer os.RemoveAll(tempDir)
|
// 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
|
root := t.TempDir()
|
||||||
testDirs := []string{
|
|
||||||
filepath.Join(tempDir, ".git"),
|
dirs := []string{
|
||||||
filepath.Join(tempDir, ".git", "objects"),
|
// case 1: not a repository
|
||||||
filepath.Join(tempDir, ".git", "refs"),
|
filepath.Join(root, "plain"),
|
||||||
filepath.Join(tempDir, "regular"),
|
|
||||||
filepath.Join(tempDir, "regular", ".git"),
|
// case 2: a repository (.git directory with contents)
|
||||||
filepath.Join(tempDir, "submodule"),
|
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 {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
t.Fatalf("failed to create dir %s: %v", dir, err)
|
t.Fatalf("failed to create dir %s: %v", dir, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create test files
|
files := map[string]string{
|
||||||
testFiles := map[string]string{
|
// sheets
|
||||||
filepath.Join(tempDir, ".gitignore"): "*.tmp\n",
|
filepath.Join(root, "plain", "sheet"): "plain sheet",
|
||||||
filepath.Join(tempDir, ".gitattributes"): "* text=auto\n",
|
filepath.Join(root, "repo", "sheet"): "repo sheet",
|
||||||
filepath.Join(tempDir, "submodule", ".git"): "gitdir: ../.git/modules/submodule\n",
|
filepath.Join(root, "submodule", "sheet"): "submod sheet",
|
||||||
filepath.Join(tempDir, "regular", "sheet.txt"): "content\n",
|
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 {
|
for path, content := range files {
|
||||||
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
t.Fatalf("failed to create file %s: %v", file, err)
|
t.Fatalf("failed to write %s: %v", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
return root
|
||||||
name string
|
|
||||||
path string
|
|
||||||
want bool
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "not in git directory",
|
|
||||||
path: filepath.Join(tempDir, "regular", "sheet.txt"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "in .git directory",
|
|
||||||
path: filepath.Join(tempDir, ".git", "objects", "file"),
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "in .git/refs directory",
|
|
||||||
path: filepath.Join(tempDir, ".git", "refs", "heads", "main"),
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: ".gitignore file",
|
|
||||||
path: filepath.Join(tempDir, ".gitignore"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: ".gitattributes file",
|
|
||||||
path: filepath.Join(tempDir, ".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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitDirEdgeCases(t *testing.T) {
|
func TestGitDir(t *testing.T) {
|
||||||
// Test with paths that have .git but not as a directory separator
|
root := setupGitDirTestTree(t)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
|
// Case 1: not a repository — no .git anywhere in path
|
||||||
{
|
{
|
||||||
name: "file ending with .git",
|
name: "plain directory, no repo",
|
||||||
path: "/tmp/myfile.git",
|
path: filepath.Join(root, "plain", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 2: a repository — paths *inside* .git/ should be detected
|
||||||
|
{
|
||||||
|
name: "inside .git directory",
|
||||||
|
path: filepath.Join(root, "repo", ".git", "HEAD"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inside .git/objects",
|
||||||
|
path: filepath.Join(root, "repo", ".git", "objects", "pack", "somefile"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inside .git/refs",
|
||||||
|
path: filepath.Join(root, "repo", ".git", "refs", "heads", "main"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 2 (cont.): files *alongside* .git should NOT be detected
|
||||||
|
{
|
||||||
|
name: "sheet in repo root (beside .git dir)",
|
||||||
|
path: filepath.Join(root, "repo", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 3: .git* files (like .gitignore) should NOT trigger
|
||||||
|
{
|
||||||
|
name: ".gitignore file",
|
||||||
|
path: filepath.Join(root, "repo", ".gitignore"),
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "directory ending with .git",
|
name: ".gitattributes file",
|
||||||
path: "/tmp/myrepo.git",
|
path: filepath.Join(root, "repo", ".gitattributes"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 4: submodule — .git is a file, not a directory
|
||||||
|
{
|
||||||
|
name: "sheet in submodule (where .git is a file)",
|
||||||
|
path: filepath.Join(root, "submodule", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 6: directory name ends with .git (#711)
|
||||||
|
{
|
||||||
|
name: "sheet under directory ending in .git",
|
||||||
|
path: filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: ".github directory",
|
name: "sheet under .git-suffixed dir, nested deeper",
|
||||||
path: "/tmp/.github/workflows",
|
path: filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// .github directory — must not be confused with .git
|
||||||
{
|
{
|
||||||
name: "legitimate.git-repo name",
|
name: "file inside .github directory",
|
||||||
path: "/tmp/legitimate.git-repo/file",
|
path: filepath.Join(root, ".github", "workflows", "ci.yml"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden directory that is not .git
|
||||||
|
{
|
||||||
|
name: "file inside generic hidden directory",
|
||||||
|
path: filepath.Join(root, ".hidden", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Path with no .git at all
|
||||||
|
{
|
||||||
|
name: "path with no .git component whatsoever",
|
||||||
|
path: filepath.Join(root, "nonexistent", "file"),
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -140,8 +194,7 @@ func TestGitDirEdgeCases(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := GitDir(tt.path)
|
got, err := GitDir(tt.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// It's ok if the path doesn't exist for these edge case tests
|
t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("GitDir(%q) = %v, want %v", tt.path, 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) {
|
// TestGitDirWithNestedGitDir tests a repo inside a .git-suffixed parent
|
||||||
// Test that the function correctly uses os.PathSeparator
|
// directory. This is the nastiest combination: a real .git directory that
|
||||||
// This is important for cross-platform compatibility
|
// 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
|
// Create: root/cheats.git/repo/.git/HEAD
|
||||||
var wrongSep string
|
// root/cheats.git/repo/sheet
|
||||||
if os.PathSeparator == '/' {
|
gitDir := filepath.Join(root, "cheats.git", "repo", ".git")
|
||||||
wrongSep = `\`
|
if err := os.MkdirAll(gitDir, 0755); err != nil {
|
||||||
} else {
|
t.Fatal(err)
|
||||||
wrongSep = `/`
|
}
|
||||||
|
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
|
tests := []struct {
|
||||||
path := fmt.Sprintf("some%spath%s.git%sfile", wrongSep, wrongSep, wrongSep)
|
name string
|
||||||
isGit, err := GitDir(path)
|
path string
|
||||||
|
want bool
|
||||||
if err != nil {
|
}{
|
||||||
// Path doesn't exist, which is fine
|
{
|
||||||
return
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if isGit {
|
for _, tt := range tests {
|
||||||
t.Errorf("GitDir() incorrectly detected git dir with wrong path separator")
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := GitDir(tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGitDirSubmoduleInsideDotGitSuffix tests a submodule (.git file)
|
||||||
|
// inside a .git-suffixed parent directory.
|
||||||
|
func TestGitDirSubmoduleInsideDotGitSuffix(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
// Create: root/personal.git/submod/.git (file)
|
||||||
|
// root/personal.git/submod/sheet
|
||||||
|
subDir := filepath.Join(root, "personal.git", "submod")
|
||||||
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// .git as a file (submodule pointer)
|
||||||
|
if err := os.WriteFile(filepath.Join(subDir, ".git"), []byte("gitdir: ../../.git/modules/sub\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(subDir, "sheet"), []byte("content"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := GitDir(filepath.Join(subDir, "sheet"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got {
|
||||||
|
t.Error("GitDir should return false for sheet in submodule under .git-suffixed parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGitDirIntegrationWalk simulates what sheets.Load does: walking a
|
||||||
|
// directory tree and checking each path with GitDir. This verifies that
|
||||||
|
// the function works correctly in the context of filepath.Walk, which is
|
||||||
|
// how it is actually called.
|
||||||
|
func TestGitDirIntegrationWalk(t *testing.T) {
|
||||||
|
root := setupGitDirTestTree(t)
|
||||||
|
|
||||||
|
// Walk the tree and collect which paths GitDir says to skip
|
||||||
|
var skipped []string
|
||||||
|
var visited []string
|
||||||
|
|
||||||
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isGit, err := GitDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isGit {
|
||||||
|
skipped = append(skipped, path)
|
||||||
|
} else {
|
||||||
|
visited = append(visited, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Walk failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files inside .git/ should be skipped
|
||||||
|
expectSkipped := []string{
|
||||||
|
filepath.Join(root, "repo", ".git", "HEAD"),
|
||||||
|
}
|
||||||
|
for _, want := range expectSkipped {
|
||||||
|
found := false
|
||||||
|
for _, got := range skipped {
|
||||||
|
if got == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected %q to be skipped, but it was not", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sheets should NOT be skipped — including the #711 case
|
||||||
|
expectVisited := []string{
|
||||||
|
filepath.Join(root, "plain", "sheet"),
|
||||||
|
filepath.Join(root, "repo", "sheet"),
|
||||||
|
filepath.Join(root, "submodule", "sheet"),
|
||||||
|
filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
|
||||||
|
filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
|
||||||
|
filepath.Join(root, ".hidden", "sheet"),
|
||||||
|
}
|
||||||
|
for _, want := range expectVisited {
|
||||||
|
found := false
|
||||||
|
for _, got := range visited {
|
||||||
|
if got == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected %q to be visited (not skipped), but it was not found in visited paths", want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user