Skip to content

feat: Add ability to configure the git worktree path via the configuration file #121

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ type Config struct {
DaemonPollInterval int `json:"daemon_poll_interval"`
// BranchPrefix is the prefix used for git branches created by the application.
BranchPrefix string `json:"branch_prefix"`
// WorktreePattern is the pattern used to determine worktree location.
// Supports variables: {repo_root}, {repo_name}, {issue_number}, {title}, {timestamp}
// Example: "{repo_root}/worktree/{issue_number}-{title}"
WorktreePattern string `json:"worktree_pattern"`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to make this apply to the branch name too. I think it's good for branch names to be 1:1 with worktree paths.

Maybe we can call it WorkStreamPattern?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you thinking the branch name would be another pattern we include?

}

// DefaultConfig returns the default configuration
Expand Down
26 changes: 20 additions & 6 deletions session/git/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,28 @@ func NewGitWorktree(repoPath string, sessionName string) (tree *GitWorktree, bra
return nil, "", err
}

worktreeDir, err := getWorktreeDirectory()
if err != nil {
return nil, "", err
var worktreePath string

// Use configured pattern if available
if cfg.WorktreePattern != "" {
vars := PatternVariables{
RepoRoot: repoPath,
RepoName: filepath.Base(repoPath),
IssueNumber: extractIssueNumber(sessionName),
Title: sanitizedName,
Timestamp: fmt.Sprintf("%x", time.Now().UnixNano()),
}
worktreePath = parseWorktreePattern(cfg.WorktreePattern, vars)
} else {
// Fall back to default behavior
worktreeDir, err := getWorktreeDirectory()
if err != nil {
return nil, "", err
}
worktreePath = filepath.Join(worktreeDir, sanitizedName)
worktreePath = worktreePath + "_" + fmt.Sprintf("%x", time.Now().UnixNano())
}

worktreePath := filepath.Join(worktreeDir, sanitizedName)
worktreePath = worktreePath + "_" + fmt.Sprintf("%x", time.Now().UnixNano())

return &GitWorktree{
repoPath: repoPath,
sessionName: sessionName,
Expand Down
12 changes: 12 additions & 0 deletions session/git/worktree_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ func (g *GitWorktree) SetupFromExistingBranch() error {
return fmt.Errorf("failed to create worktrees directory: %w", err)
}

// Ensure the parent directory of the worktree path exists
worktreeParent := filepath.Dir(g.worktreePath)
if err := os.MkdirAll(worktreeParent, 0755); err != nil {
return fmt.Errorf("failed to create worktree parent directory: %w", err)
}

// Clean up any existing worktree first
_, _ = g.runGitCommand(g.repoPath, "worktree", "remove", "-f", g.worktreePath) // Ignore error if worktree doesn't exist

Expand All @@ -57,6 +63,12 @@ func (g *GitWorktree) SetupNewWorktree() error {
return fmt.Errorf("failed to create worktrees directory: %w", err)
}

// Ensure the parent directory of the worktree path exists
worktreeParent := filepath.Dir(g.worktreePath)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please explain why this is needed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well git worktree add requires a directory to create worktree. Since the SetupNewWorktree and SetupFromExistingBranch are separately called in the Setup we need do do this in both of them.

So this checks for the directory and creates it if it does not exist to then be able to perform the git worktree add.

if err := os.MkdirAll(worktreeParent, 0755); err != nil {
return fmt.Errorf("failed to create worktree parent directory: %w", err)
}

// Clean up any existing worktree first
_, _ = g.runGitCommand(g.repoPath, "worktree", "remove", "-f", g.worktreePath) // Ignore error if worktree doesn't exist

Expand Down
139 changes: 139 additions & 0 deletions session/git/worktree_pattern.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package git

import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"
"time"
)

// PatternVariables holds the available variables for worktree pattern substitution
type PatternVariables struct {
RepoRoot string
RepoName string
IssueNumber string
Title string
Timestamp string
}

// parseWorktreePattern substitutes variables in the pattern with actual values
func parseWorktreePattern(pattern string, vars PatternVariables) string {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can usetext/template to do this replacing operation.

Copy link
Author

@nbperry nbperry Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I will update to this approach,I am pretty new to go so still learning alot of things 😅

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated via 5908ac6

if pattern == "" {
return ""
}

// If timestamp is empty, generate it
if vars.Timestamp == "" {
vars.Timestamp = fmt.Sprintf("%x", time.Now().UnixNano())
}

// Convert the pattern from {variable} format to {{.Variable}} format for text/template
templatePattern := convertToTemplateFormat(pattern)

// Create and parse the template
tmpl, err := template.New("worktree").Parse(templatePattern)
if err != nil {
// If template parsing fails, fallback to original pattern
return pattern
}

// Execute the template with the variables
var result strings.Builder
err = tmpl.Execute(&result, vars)
if err != nil {
// If template execution fails, fallback to original pattern
return pattern
}

output := result.String()

// Clean up delimiters from empty variables
output = cleanupDelimiters(output)

// Expand tilde to home directory
if strings.HasPrefix(output, "~/") {
homeDir, err := os.UserHomeDir()
if err == nil {
output = filepath.Join(homeDir, output[2:])
}
}

// Clean up the path
output = filepath.Clean(output)

return output
}

// convertToTemplateFormat converts {variable} format to {{.Variable}} format for text/template
func convertToTemplateFormat(pattern string) string {
// Map of old format to new format
replacements := map[string]string{
"{repo_root}": "{{.RepoRoot}}",
"{repo_name}": "{{.RepoName}}",
"{issue_number}": "{{.IssueNumber}}",
"{title}": "{{.Title}}",
"{timestamp}": "{{.Timestamp}}",
}

result := pattern
for old, new := range replacements {
result = strings.ReplaceAll(result, old, new)
}

return result
}

// cleanupDelimiters removes unnecessary delimiters left by empty variables
func cleanupDelimiters(s string) string {
// Common delimiters to clean up
delimiters := "-_.:"

// Remove leading delimiters
s = strings.TrimLeft(s, delimiters)

// Remove trailing delimiters
s = strings.TrimRight(s, delimiters)

// Replace multiple consecutive delimiters with a single one
// We need to handle each delimiter type separately to preserve the original delimiter
for _, delim := range delimiters {
delimStr := string(delim)
multiple := delimStr + delimStr
for strings.Contains(s, multiple) {
s = strings.ReplaceAll(s, multiple, delimStr)
}
}

// Special case: remove delimiter before or after path separator
// e.g., "/-" -> "/", "-/" -> "/"
for _, delim := range delimiters {
s = strings.ReplaceAll(s, "/"+string(delim), "/")
s = strings.ReplaceAll(s, string(delim)+"/", "/")
}

return s
}

// extractIssueNumber attempts to extract an issue number from the session name
func extractIssueNumber(sessionName string) string {
// Look for patterns like "#123", "issue-123", "issue/123", etc.
patterns := []string{
`#(\d+)`,
`issue[-/](\d+)`,
`(\d+)[-_]`,
`^(\d+)$`,
}

for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(sessionName)
if len(matches) > 1 {
return matches[1]
}
}

return ""
}
Loading
Loading