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