From e0c822dd55b1259502a41fa11e854dc48e290991 Mon Sep 17 00:00:00 2001 From: kostyay Date: Wed, 11 Feb 2026 01:04:26 +0100 Subject: [PATCH] feat: resolve .ktickets relative to git root Previously .ktickets resolved relative to cwd, so running kt from a subdirectory placed tickets in the wrong location. Now walks up the directory tree to find .git and places .ktickets beside it. GenerateID also uses the git root dir name for consistent ticket prefixes. Falls back to cwd with a stderr warning when not in a git repo. KTICKET_DIR env var still takes priority. Co-Authored-By: Claude Opus 4.6 --- internal/config/config.go | 16 +++++++++++--- internal/config/config_test.go | 35 ++++++++++++++++++----------- internal/config/gitroot.go | 29 ++++++++++++++++++++++++ internal/config/gitroot_test.go | 39 +++++++++++++++++++++++++++++++++ internal/store/id.go | 31 +++++++++++++++++--------- 5 files changed, 124 insertions(+), 26 deletions(-) create mode 100644 internal/config/gitroot.go create mode 100644 internal/config/gitroot_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 7c5e695..64ea859 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,10 @@ package config -import "os" +import ( + "fmt" + "os" + "path/filepath" +) const ( // DefaultDir is the default directory for storing tickets. @@ -11,10 +15,16 @@ const ( ) // Dir returns the tickets directory. -// Checks KTICKET_DIR env var first, falls back to DefaultDir. +// Checks KTICKET_DIR env var first, then resolves relative to git root, +// falls back to DefaultDir in cwd if not in a git repo. func Dir() string { if dir := os.Getenv(EnvDir); dir != "" { return dir } - return DefaultDir + gitRoot, err := FindGitRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %v; using ./%s\n", err, DefaultDir) + return DefaultDir + } + return filepath.Join(gitRoot, DefaultDir) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a36289e..b6a7c38 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,28 +2,37 @@ package config import ( "os" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestDirDefault(t *testing.T) { - os.Unsetenv(EnvDir) - assert.Equal(t, DefaultDir, Dir()) - assert.Equal(t, ".ktickets", Dir()) +func TestDirEnvOverride(t *testing.T) { + t.Setenv(EnvDir, "/custom/path") + assert.Equal(t, "/custom/path", Dir()) } -func TestDirEnvOverride(t *testing.T) { - os.Setenv(EnvDir, "/custom/path") - defer os.Unsetenv(EnvDir) +func TestDirUsesGitRoot(t *testing.T) { + t.Setenv(EnvDir, "") - assert.Equal(t, "/custom/path", Dir()) + dir := Dir() + assert.True(t, filepath.IsAbs(dir), "expected absolute path, got %s", dir) + assert.True(t, strings.HasSuffix(dir, DefaultDir)) } -func TestDirEnvEmpty(t *testing.T) { - os.Setenv(EnvDir, "") - defer os.Unsetenv(EnvDir) +func TestDirFallbackNoGitRoot(t *testing.T) { + t.Setenv(EnvDir, "") + + // cd to a temp dir with no .git + tmp := t.TempDir() + orig, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(orig) - // Empty env var should fall back to default - assert.Equal(t, DefaultDir, Dir()) + dir := Dir() + assert.Equal(t, DefaultDir, dir) } diff --git a/internal/config/gitroot.go b/internal/config/gitroot.go new file mode 100644 index 0000000..802b631 --- /dev/null +++ b/internal/config/gitroot.go @@ -0,0 +1,29 @@ +package config + +import ( + "errors" + "os" + "path/filepath" +) + +// FindGitRoot walks from cwd upward looking for a .git directory. +func FindGitRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return findGitRootFrom(cwd) +} + +func findGitRootFrom(dir string) (string, error) { + for { + if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil && info.IsDir() { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", errors.New("not a git repository (or any parent up to /)") + } + dir = parent + } +} diff --git a/internal/config/gitroot_test.go b/internal/config/gitroot_test.go new file mode 100644 index 0000000..e07780a --- /dev/null +++ b/internal/config/gitroot_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindGitRootFromRepoRoot(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755)) + + got, err := findGitRootFrom(root) + require.NoError(t, err) + assert.Equal(t, root, got) +} + +func TestFindGitRootFromSubdir(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755)) + + sub := filepath.Join(root, "a", "b", "c") + require.NoError(t, os.MkdirAll(sub, 0o755)) + + got, err := findGitRootFrom(sub) + require.NoError(t, err) + assert.Equal(t, root, got) +} + +func TestFindGitRootNotFound(t *testing.T) { + dir := t.TempDir() + + _, err := findGitRootFrom(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a git repository") +} diff --git a/internal/store/id.go b/internal/store/id.go index e4bd4e7..6d24048 100644 --- a/internal/store/id.go +++ b/internal/store/id.go @@ -7,17 +7,18 @@ import ( "path/filepath" "strings" "time" + + "github.com/kostyay/kticket/internal/config" ) -// GenerateID creates a unique ticket ID based on the current directory name. +// GenerateID creates a unique ticket ID based on the git root directory name. +// Falls back to cwd name if not in a git repo. func GenerateID() (string, error) { - cwd, err := os.Getwd() + dir, err := projectDirName() if err != nil { return "", err } - dir := filepath.Base(cwd) - // Extract prefix from directory name prefix := extractPrefix(dir) // 4-char hash from PID + timestamp @@ -27,25 +28,35 @@ func GenerateID() (string, error) { return fmt.Sprintf("%s-%s", prefix, hash), nil } +// projectDirName returns the base name of the git root, or cwd as fallback. +func projectDirName() (string, error) { + gitRoot, err := config.FindGitRoot() + if err == nil { + return filepath.Base(gitRoot), nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Base(cwd), nil +} + // extractPrefix derives a short prefix from a directory/project name. func extractPrefix(name string) string { - // Split on - or _ parts := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' }) if len(parts) == 1 { - // No separators: use first 1-3 chars if len(name) > 3 { return name[:3] } return name } - // First letter of each part - var prefix string + var b strings.Builder for _, p := range parts { if len(p) > 0 { - prefix += string(p[0]) + b.WriteByte(p[0]) } } - return prefix + return b.String() }