feat(v0.5.3): SessionName deterministic tmux session naming
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SessionName returns a stable tmux session name for the given workspace.
|
||||
// Format: ctask-<category>-<slug>-<hash6>.
|
||||
//
|
||||
// category and slug are sanitized to [A-Za-z0-9_-]; characters outside that
|
||||
// set become '_'; runs of '_' collapse; both are lowercased. slug is
|
||||
// truncated to 30 characters maximum (after sanitization). hash6 is the
|
||||
// first six lowercase-hex characters of sha256(canonical absolute path).
|
||||
//
|
||||
// On Windows the path is lowercased before hashing to match
|
||||
// config.searchRootKey conventions for case-insensitive filesystems.
|
||||
func SessionName(category, slug, absWsPath string) string {
|
||||
cat := sanitizeNameComponent(category)
|
||||
sl := sanitizeNameComponent(slug)
|
||||
if len(sl) > 30 {
|
||||
sl = sl[:30]
|
||||
sl = strings.TrimRight(sl, "_")
|
||||
}
|
||||
clean := filepath.Clean(absWsPath)
|
||||
if runtime.GOOS == "windows" {
|
||||
clean = strings.ToLower(clean)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(clean))
|
||||
hash := hex.EncodeToString(sum[:])[:6]
|
||||
return "ctask-" + cat + "-" + sl + "-" + hash
|
||||
}
|
||||
|
||||
// sanitizeNameComponent replaces every char outside [A-Za-z0-9_-] with '_',
|
||||
// collapses runs of '_', trims leading/trailing '_' and '-', and lowercases.
|
||||
func sanitizeNameComponent(s string) string {
|
||||
if s == "" {
|
||||
return "_"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r + ('a' - 'A'))
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '_' || r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
for strings.Contains(out, "__") {
|
||||
out = strings.ReplaceAll(out, "__", "_")
|
||||
}
|
||||
out = strings.Trim(out, "_-")
|
||||
if out == "" {
|
||||
return "_"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionNameStableAcrossRuns(t *testing.T) {
|
||||
a := SessionName("projects", "promptvolley-v3", "/home/warren/ai-workspaces/projects/promptvolley-v3")
|
||||
b := SessionName("projects", "promptvolley-v3", "/home/warren/ai-workspaces/projects/promptvolley-v3")
|
||||
if a != b {
|
||||
t.Errorf("not stable: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNamePrefixAndShape(t *testing.T) {
|
||||
got := SessionName("projects", "promptvolley-v3", "/abs/path/promptvolley-v3")
|
||||
if !strings.HasPrefix(got, "ctask-projects-promptvolley-v3-") {
|
||||
t.Errorf("unexpected prefix: %q", got)
|
||||
}
|
||||
parts := strings.Split(got, "-")
|
||||
hash := parts[len(parts)-1]
|
||||
if len(hash) != 6 {
|
||||
t.Errorf("expected 6-char hash suffix, got %q (full %q)", hash, got)
|
||||
}
|
||||
for _, c := range hash {
|
||||
ok := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
|
||||
if !ok {
|
||||
t.Errorf("hash must be lowercase hex: %q", hash)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNameSanitizesUnsafeCharacters(t *testing.T) {
|
||||
got := SessionName("My Cat/Egory", "weird name with spaces & punct!", "/abs/path/x")
|
||||
if strings.ContainsAny(got, " /&!?") {
|
||||
t.Errorf("sanitization missed unsafe chars: %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "ctask-my_cat_egory-") {
|
||||
t.Errorf("category not lowercased+sanitized: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNameSlugTruncatedAt30(t *testing.T) {
|
||||
long := strings.Repeat("a", 50)
|
||||
got := SessionName("projects", long, "/abs/path/x")
|
||||
parts := strings.Split(got, "-")
|
||||
if len(parts) < 4 {
|
||||
t.Fatalf("unexpected shape: %q", got)
|
||||
}
|
||||
slugTokens := parts[2 : len(parts)-1]
|
||||
slug := strings.Join(slugTokens, "-")
|
||||
if len(slug) > 30 {
|
||||
t.Errorf("slug not truncated to 30: got %d chars (%q)", len(slug), slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNameDifferentPathsCollideOnSlugButNotOverall(t *testing.T) {
|
||||
a := SessionName("projects", "demo", "/path/one/demo")
|
||||
b := SessionName("projects", "demo", "/path/two/demo")
|
||||
if a == b {
|
||||
t.Errorf("hash should differ on path: both %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNameWindowsPathCaseInsensitive(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows-only path canonicalization")
|
||||
}
|
||||
a := SessionName("projects", "demo", "C:\\Users\\Warren\\X")
|
||||
b := SessionName("projects", "demo", "c:\\users\\warren\\x")
|
||||
if a != b {
|
||||
t.Errorf("Windows paths must be case-insensitive: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNameRunsOfUnderscoresCollapsed(t *testing.T) {
|
||||
got := SessionName("a b", "x y", "/abs/x")
|
||||
if strings.Contains(got, "__") {
|
||||
t.Errorf("runs of _ must collapse: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user