Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package config

import "os"
import (
"fmt"
"os"
"path/filepath"
)

const (
// DefaultDir is the default directory for storing tickets.
Expand All @@ -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)
}
35 changes: 22 additions & 13 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
29 changes: 29 additions & 0 deletions internal/config/gitroot.go
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 39 additions & 0 deletions internal/config/gitroot_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
31 changes: 21 additions & 10 deletions internal/store/id.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}