Compare commits

...

5 Commits

Author SHA1 Message Date
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
12 changed files with 411 additions and 210 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 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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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