feat(v0.5.3): SessionName deterministic tmux session naming

This commit is contained in:
2026-05-08 13:47:01 -04:00
parent e448effd2f
commit 32fa5d0d21
2 changed files with 152 additions and 0 deletions
+68
View File
@@ -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
}
+84
View File
@@ -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)
}
}