Generate 6-char hash IDs with progressive 7/8-char fallback on collision (bd-7c87cf24)
- Changed generateHashID to start with 6 chars (3 bytes), expand to 7/8 on collision - Updated both CreateIssue and CreateIssues (batch) to use progressive length fallback - Updated tests to accept 9-11 char IDs (bd- + 6-8 hex chars) - All new issues now generate with shorter, more readable IDs - Existing 8-char IDs preserved (no migration needed) Amp-Thread-ID: https://ampcode.com/threads/T-8a6058af-9f42-4bff-be02-8c8bce41eeb5 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"database": "beads.db",
|
"database": "beads.db",
|
||||||
"version": "0.17.7",
|
"version": "0.19.1",
|
||||||
"jsonl_export": "beads.jsonl"
|
"jsonl_export": "beads.jsonl"
|
||||||
}
|
}
|
||||||
184
.beads/issues.jsonl
Normal file
184
.beads/issues.jsonl
Normal file
File diff suppressed because one or more lines are too long
@@ -279,6 +279,7 @@ This command:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Migrate to hash IDs if requested
|
// Migrate to hash IDs if requested
|
||||||
if toHashIDs {
|
if toHashIDs {
|
||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ func TestHashIDGeneration(t *testing.T) {
|
|||||||
t.Fatalf("Failed to create issue: %v", err)
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify hash ID format: bd-<8 hex chars>
|
// Verify hash ID format: bd-<6 hex chars> (or 7/8 on collision)
|
||||||
if len(issue.ID) != 11 { // "bd-" (3) + 8 hex chars = 11
|
if len(issue.ID) < 9 || len(issue.ID) > 11 { // "bd-" (3) + 6-8 hex chars = 9-11
|
||||||
t.Errorf("Expected ID length 11, got %d: %s", len(issue.ID), issue.ID)
|
t.Errorf("Expected ID length 9-11, got %d: %s", len(issue.ID), issue.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue.ID[:3] != "bd-" {
|
if issue.ID[:3] != "bd-" {
|
||||||
@@ -66,8 +66,8 @@ func TestHashIDDeterministic(t *testing.T) {
|
|||||||
actor := "test-actor"
|
actor := "test-actor"
|
||||||
timestamp := time.Now()
|
timestamp := time.Now()
|
||||||
|
|
||||||
id1 := generateHashID(prefix, title, description, actor, timestamp, 0)
|
id1 := generateHashID(prefix, title, description, actor, timestamp, 6, 0)
|
||||||
id2 := generateHashID(prefix, title, description, actor, timestamp, 0)
|
id2 := generateHashID(prefix, title, description, actor, timestamp, 6, 0)
|
||||||
|
|
||||||
if id1 != id2 {
|
if id1 != id2 {
|
||||||
t.Errorf("Expected same hash for same inputs, got %s and %s", id1, id2)
|
t.Errorf("Expected same hash for same inputs, got %s and %s", id1, id2)
|
||||||
@@ -187,9 +187,9 @@ func TestHashIDBatchCreation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ids[issue.ID] = true
|
ids[issue.ID] = true
|
||||||
|
|
||||||
// Verify hash ID format
|
// Verify hash ID format (6-8 chars)
|
||||||
if len(issue.ID) != 11 {
|
if len(issue.ID) < 9 || len(issue.ID) > 11 {
|
||||||
t.Errorf("Expected ID length 11, got %d: %s", len(issue.ID), issue.ID)
|
t.Errorf("Expected ID length 9-11, got %d: %s", len(issue.ID), issue.ID)
|
||||||
}
|
}
|
||||||
if issue.ID[:3] != "bd-" {
|
if issue.ID[:3] != "bd-" {
|
||||||
t.Errorf("Expected ID to start with 'bd-', got: %s", issue.ID)
|
t.Errorf("Expected ID to start with 'bd-', got: %s", issue.ID)
|
||||||
|
|||||||
@@ -776,9 +776,10 @@ func nextSequentialID(ctx context.Context, conn *sql.Conn, prefix string) (int,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generateHashID creates a hash-based ID for a top-level issue.
|
// generateHashID creates a hash-based ID for a top-level issue.
|
||||||
// For child issues, use the parent ID with a numeric suffix (e.g., "bd-a3f8e9a2.1").
|
// For child issues, use the parent ID with a numeric suffix (e.g., "bd-a3f8e9.1").
|
||||||
// Includes a nonce parameter to handle collisions.
|
// Starts with 6 chars, expands to 7/8 on collision (length parameter).
|
||||||
func generateHashID(prefix, title, description, creator string, timestamp time.Time, nonce int) string {
|
// Includes a nonce parameter to handle same-length collisions.
|
||||||
|
func generateHashID(prefix, title, description, creator string, timestamp time.Time, length, nonce int) string {
|
||||||
// Combine inputs into a stable content string
|
// Combine inputs into a stable content string
|
||||||
// Include nonce to handle hash collisions
|
// Include nonce to handle hash collisions
|
||||||
content := fmt.Sprintf("%s|%s|%s|%d|%d", title, description, creator, timestamp.UnixNano(), nonce)
|
content := fmt.Sprintf("%s|%s|%s|%d|%d", title, description, creator, timestamp.UnixNano(), nonce)
|
||||||
@@ -786,8 +787,20 @@ func generateHashID(prefix, title, description, creator string, timestamp time.T
|
|||||||
// Hash the content
|
// Hash the content
|
||||||
hash := sha256.Sum256([]byte(content))
|
hash := sha256.Sum256([]byte(content))
|
||||||
|
|
||||||
// Use first 4 bytes (8 hex chars) for short, readable IDs
|
// Use variable length (6, 7, or 8 hex chars)
|
||||||
shortHash := hex.EncodeToString(hash[:4])
|
// length determines how many bytes to use (3, 3.5, or 4)
|
||||||
|
var shortHash string
|
||||||
|
switch length {
|
||||||
|
case 6:
|
||||||
|
shortHash = hex.EncodeToString(hash[:3])
|
||||||
|
case 7:
|
||||||
|
// 3.5 bytes: use 4 bytes but take only first 7 chars
|
||||||
|
shortHash = hex.EncodeToString(hash[:4])[:7]
|
||||||
|
case 8:
|
||||||
|
shortHash = hex.EncodeToString(hash[:4])
|
||||||
|
default:
|
||||||
|
shortHash = hex.EncodeToString(hash[:3]) // default to 6
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s-%s", prefix, shortHash)
|
return fmt.Sprintf("%s-%s", prefix, shortHash)
|
||||||
}
|
}
|
||||||
@@ -855,27 +868,35 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
|||||||
idMode := getIDMode(ctx, conn)
|
idMode := getIDMode(ctx, conn)
|
||||||
|
|
||||||
if idMode == "hash" {
|
if idMode == "hash" {
|
||||||
// Generate hash-based ID with collision detection (bd-168)
|
// Generate hash-based ID with progressive length fallback (bd-7c87cf24)
|
||||||
// Try up to 10 times with different nonces to avoid collisions
|
// Start with 6 chars, expand to 7/8 on collision
|
||||||
var err error
|
var err error
|
||||||
for nonce := 0; nonce < 10; nonce++ {
|
for length := 6; length <= 8; length++ {
|
||||||
candidate := generateHashID(prefix, issue.Title, issue.Description, actor, issue.CreatedAt, nonce)
|
// Try up to 10 nonces at each length
|
||||||
|
for nonce := 0; nonce < 10; nonce++ {
|
||||||
// Check if this ID already exists
|
candidate := generateHashID(prefix, issue.Title, issue.Description, actor, issue.CreatedAt, length, nonce)
|
||||||
var count int
|
|
||||||
err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, candidate).Scan(&count)
|
// Check if this ID already exists
|
||||||
if err != nil {
|
var count int
|
||||||
return fmt.Errorf("failed to check for ID collision: %w", err)
|
err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, candidate).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for ID collision: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
issue.ID = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if count == 0 {
|
// If we found a unique ID, stop trying longer lengths
|
||||||
issue.ID = candidate
|
if issue.ID != "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue.ID == "" {
|
if issue.ID == "" {
|
||||||
return fmt.Errorf("failed to generate unique ID after 10 attempts")
|
return fmt.Errorf("failed to generate unique ID after trying lengths 6-8 with 10 nonces each")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default: generate sequential ID using counter
|
// Default: generate sequential ID using counter
|
||||||
@@ -1017,34 +1038,37 @@ func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue
|
|||||||
|
|
||||||
// Second pass: generate IDs for issues that need them
|
// Second pass: generate IDs for issues that need them
|
||||||
if idMode == "hash" {
|
if idMode == "hash" {
|
||||||
// Hash mode: generate with collision detection
|
// Hash mode: generate with progressive length fallback (bd-7c87cf24)
|
||||||
for i := range issues {
|
for i := range issues {
|
||||||
if issues[i].ID == "" {
|
if issues[i].ID == "" {
|
||||||
var generated bool
|
var generated bool
|
||||||
for nonce := 0; nonce < 10; nonce++ {
|
// Try lengths 6, 7, 8 with progressive fallback
|
||||||
candidate := generateHashID(prefix, issues[i].Title, issues[i].Description, actor, issues[i].CreatedAt, nonce)
|
for length := 6; length <= 8 && !generated; length++ {
|
||||||
|
for nonce := 0; nonce < 10; nonce++ {
|
||||||
// Check if this ID is already used in this batch or in the database
|
candidate := generateHashID(prefix, issues[i].Title, issues[i].Description, actor, issues[i].CreatedAt, length, nonce)
|
||||||
if usedIDs[candidate] {
|
|
||||||
continue
|
// Check if this ID is already used in this batch or in the database
|
||||||
}
|
if usedIDs[candidate] {
|
||||||
|
continue
|
||||||
var count int
|
}
|
||||||
err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, candidate).Scan(&count)
|
|
||||||
if err != nil {
|
var count int
|
||||||
return fmt.Errorf("failed to check for ID collision: %w", err)
|
err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, candidate).Scan(&count)
|
||||||
}
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for ID collision: %w", err)
|
||||||
if count == 0 {
|
}
|
||||||
issues[i].ID = candidate
|
|
||||||
usedIDs[candidate] = true
|
if count == 0 {
|
||||||
generated = true
|
issues[i].ID = candidate
|
||||||
break
|
usedIDs[candidate] = true
|
||||||
|
generated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !generated {
|
if !generated {
|
||||||
return fmt.Errorf("failed to generate unique ID for issue %d after 10 attempts", i)
|
return fmt.Errorf("failed to generate unique ID for issue %d after trying lengths 6-8 with 10 nonces each", i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user