Skip to content

Commit 4d8c78a

Browse files
bbornclaude
andcommitted
Merge origin/main into task branch, resolve conflicts in retry/quick input
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 72d5871 + 2eee176 commit 4d8c78a

File tree

13 files changed

+407
-114
lines changed

13 files changed

+407
-114
lines changed

internal/config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ func (c *Config) GetProjectDir(project string) string {
6262
return filepath.Join(c.ProjectsDir, project)
6363
}
6464

65+
// ProjectUsesWorktrees returns whether a project uses git worktrees for task isolation.
66+
// Returns true by default (for backward compatibility and unknown projects).
67+
func (c *Config) ProjectUsesWorktrees(project string) bool {
68+
if project == "" {
69+
return true
70+
}
71+
p, err := c.db.GetProjectByName(project)
72+
if err == nil && p != nil {
73+
return p.UsesWorktrees()
74+
}
75+
return true
76+
}
77+
6578
// SetProjectsDir sets the default projects directory.
6679
func (c *Config) SetProjectsDir(dir string) error {
6780
if err := c.db.SetSetting(SettingProjectsDir, dir); err != nil {

internal/db/sqlite.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ func (db *DB) migrate() error {
269269
`ALTER TABLE tasks ADD COLUMN source_branch TEXT DEFAULT ''`, // Existing branch to checkout instead of creating new branch
270270
// Cached PR state as JSON for instant display on startup (avoids waiting for GitHub API)
271271
`ALTER TABLE tasks ADD COLUMN pr_info_json TEXT DEFAULT ''`, // JSON blob of github.PRInfo
272+
// Whether project uses git worktrees for task isolation (1=yes, 0=no, default 1 for backward compat)
273+
`ALTER TABLE projects ADD COLUMN use_worktrees INTEGER DEFAULT 1`,
272274
}
273275

274276
for _, m := range alterMigrations {

internal/db/sqlite_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,83 @@ func TestProjectContext(t *testing.T) {
193193
t.Error("expected error when setting context for non-existent project")
194194
}
195195
}
196+
197+
func TestProjectUseWorktrees(t *testing.T) {
198+
tmpDir := t.TempDir()
199+
dbPath := filepath.Join(tmpDir, "test.db")
200+
201+
db, err := Open(dbPath)
202+
if err != nil {
203+
t.Fatalf("failed to open database: %v", err)
204+
}
205+
defer db.Close()
206+
207+
// Default project (personal) should use worktrees
208+
personal, err := db.GetProjectByName("personal")
209+
if err != nil {
210+
t.Fatalf("failed to get personal project: %v", err)
211+
}
212+
if !personal.UsesWorktrees() {
213+
t.Error("personal project should default to using worktrees")
214+
}
215+
216+
// Create a project with worktrees enabled (default)
217+
gitProject := &Project{Name: "git-project", Path: filepath.Join(tmpDir, "git"), UseWorktrees: true}
218+
if err := db.CreateProject(gitProject); err != nil {
219+
t.Fatalf("failed to create git project: %v", err)
220+
}
221+
222+
// Create a project with worktrees disabled
223+
noGitProject := &Project{Name: "no-git-project", Path: filepath.Join(tmpDir, "nogit"), UseWorktrees: false}
224+
if err := db.CreateProject(noGitProject); err != nil {
225+
t.Fatalf("failed to create no-git project: %v", err)
226+
}
227+
228+
// Verify via GetProjectByName
229+
p, err := db.GetProjectByName("git-project")
230+
if err != nil {
231+
t.Fatalf("failed to get git-project: %v", err)
232+
}
233+
if !p.UsesWorktrees() {
234+
t.Error("git-project should use worktrees")
235+
}
236+
237+
p, err = db.GetProjectByName("no-git-project")
238+
if err != nil {
239+
t.Fatalf("failed to get no-git-project: %v", err)
240+
}
241+
if p.UsesWorktrees() {
242+
t.Error("no-git-project should NOT use worktrees")
243+
}
244+
245+
// Verify via ListProjects
246+
projects, err := db.ListProjects()
247+
if err != nil {
248+
t.Fatalf("failed to list projects: %v", err)
249+
}
250+
for _, proj := range projects {
251+
switch proj.Name {
252+
case "git-project":
253+
if !proj.UsesWorktrees() {
254+
t.Error("git-project should use worktrees in list")
255+
}
256+
case "no-git-project":
257+
if proj.UsesWorktrees() {
258+
t.Error("no-git-project should NOT use worktrees in list")
259+
}
260+
}
261+
}
262+
263+
// Test updating worktrees setting
264+
noGitProject.UseWorktrees = true
265+
if err := db.UpdateProject(noGitProject); err != nil {
266+
t.Fatalf("failed to update project: %v", err)
267+
}
268+
p, err = db.GetProjectByName("no-git-project")
269+
if err != nil {
270+
t.Fatalf("failed to get updated project: %v", err)
271+
}
272+
if !p.UsesWorktrees() {
273+
t.Error("no-git-project should use worktrees after update")
274+
}
275+
}

internal/db/tasks.go

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,9 +1076,16 @@ type Project struct {
10761076
Actions []ProjectAction // actions triggered on task events (stored as JSON)
10771077
Color string // hex color for display (e.g., "#61AFEF")
10781078
ClaudeConfigDir string // override CLAUDE_CONFIG_DIR for this project
1079+
UseWorktrees bool // whether to use git worktrees for task isolation (default true)
10791080
CreatedAt LocalTime
10801081
}
10811082

1083+
// UsesWorktrees returns whether this project uses git worktrees for task isolation.
1084+
// Defaults to true for backward compatibility.
1085+
func (p *Project) UsesWorktrees() bool {
1086+
return p.UseWorktrees
1087+
}
1088+
10821089
// GetAction returns the action for a given trigger, or nil if not found.
10831090
func (p *Project) GetAction(trigger string) *ProjectAction {
10841091
for i := range p.Actions {
@@ -1092,10 +1099,14 @@ func (p *Project) GetAction(trigger string) *ProjectAction {
10921099
// CreateProject creates a new project.
10931100
func (db *DB) CreateProject(p *Project) error {
10941101
actionsJSON, _ := json.Marshal(p.Actions)
1102+
useWorktrees := 1
1103+
if !p.UseWorktrees {
1104+
useWorktrees = 0
1105+
}
10951106
result, err := db.Exec(`
1096-
INSERT INTO projects (name, path, aliases, instructions, actions, color, claude_config_dir)
1097-
VALUES (?, ?, ?, ?, ?, ?, ?)
1098-
`, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir)
1107+
INSERT INTO projects (name, path, aliases, instructions, actions, color, claude_config_dir, use_worktrees)
1108+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1109+
`, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, useWorktrees)
10991110
if err != nil {
11001111
return fmt.Errorf("insert project: %w", err)
11011112
}
@@ -1107,10 +1118,14 @@ func (db *DB) CreateProject(p *Project) error {
11071118
// UpdateProject updates a project.
11081119
func (db *DB) UpdateProject(p *Project) error {
11091120
actionsJSON, _ := json.Marshal(p.Actions)
1121+
useWorktrees := 1
1122+
if !p.UseWorktrees {
1123+
useWorktrees = 0
1124+
}
11101125
_, err := db.Exec(`
1111-
UPDATE projects SET name = ?, path = ?, aliases = ?, instructions = ?, actions = ?, color = ?, claude_config_dir = ?
1126+
UPDATE projects SET name = ?, path = ?, aliases = ?, instructions = ?, actions = ?, color = ?, claude_config_dir = ?, use_worktrees = ?
11121127
WHERE id = ?
1113-
`, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, p.ID)
1128+
`, p.Name, p.Path, p.Aliases, p.Instructions, string(actionsJSON), p.Color, p.ClaudeConfigDir, useWorktrees, p.ID)
11141129
if err != nil {
11151130
return fmt.Errorf("update project: %w", err)
11161131
}
@@ -1151,7 +1166,7 @@ func (db *DB) CountTasksByProject(projectName string) (int, error) {
11511166
// ListProjects returns all projects, with "personal" always first.
11521167
func (db *DB) ListProjects() ([]*Project, error) {
11531168
rows, err := db.Query(`
1154-
SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), created_at
1169+
SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), COALESCE(use_worktrees, 1), created_at
11551170
FROM projects ORDER BY CASE WHEN name = 'personal' THEN 0 ELSE 1 END, name
11561171
`)
11571172
if err != nil {
@@ -1163,10 +1178,12 @@ func (db *DB) ListProjects() ([]*Project, error) {
11631178
for rows.Next() {
11641179
p := &Project{}
11651180
var actionsJSON string
1166-
if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &p.CreatedAt); err != nil {
1181+
var useWorktrees int
1182+
if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &useWorktrees, &p.CreatedAt); err != nil {
11671183
return nil, fmt.Errorf("scan project: %w", err)
11681184
}
11691185
json.Unmarshal([]byte(actionsJSON), &p.Actions)
1186+
p.UseWorktrees = useWorktrees != 0
11701187
projects = append(projects, p)
11711188
}
11721189
return projects, nil
@@ -1177,31 +1194,34 @@ func (db *DB) GetProjectByName(name string) (*Project, error) {
11771194
// First try exact name match
11781195
p := &Project{}
11791196
var actionsJSON string
1197+
var useWorktrees int
11801198
err := db.QueryRow(`
1181-
SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), created_at
1199+
SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), COALESCE(use_worktrees, 1), created_at
11821200
FROM projects WHERE name = ?
1183-
`, name).Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &p.CreatedAt)
1201+
`, name).Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &useWorktrees, &p.CreatedAt)
11841202
if err == nil {
11851203
json.Unmarshal([]byte(actionsJSON), &p.Actions)
1204+
p.UseWorktrees = useWorktrees != 0
11861205
return p, nil
11871206
}
11881207
if err != sql.ErrNoRows {
11891208
return nil, fmt.Errorf("query project: %w", err)
11901209
}
11911210

11921211
// Try alias match
1193-
rows, err := db.Query(`SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), created_at FROM projects`)
1212+
rows, err := db.Query(`SELECT id, name, path, aliases, instructions, COALESCE(actions, '[]'), COALESCE(color, ''), COALESCE(claude_config_dir, ''), COALESCE(use_worktrees, 1), created_at FROM projects`)
11941213
if err != nil {
11951214
return nil, fmt.Errorf("query projects: %w", err)
11961215
}
11971216
defer rows.Close()
11981217

11991218
for rows.Next() {
12001219
p := &Project{}
1201-
if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &p.CreatedAt); err != nil {
1220+
if err := rows.Scan(&p.ID, &p.Name, &p.Path, &p.Aliases, &p.Instructions, &actionsJSON, &p.Color, &p.ClaudeConfigDir, &useWorktrees, &p.CreatedAt); err != nil {
12021221
return nil, fmt.Errorf("scan project: %w", err)
12031222
}
12041223
json.Unmarshal([]byte(actionsJSON), &p.Actions)
1224+
p.UseWorktrees = useWorktrees != 0
12051225
for _, alias := range splitAliases(p.Aliases) {
12061226
if alias == name {
12071227
return p, nil

internal/executor/executor.go

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,12 @@ func (e *Executor) cleanupStaleWorktrees() {
847847
continue
848848
}
849849

850+
// Skip non-worktree projects - they share the project directory and should not be archived
851+
if !e.config.ProjectUsesWorktrees(task.Project) {
852+
e.db.ClearTaskWorktreePath(task.ID)
853+
continue
854+
}
855+
850856
// Skip if worktree path doesn't exist on disk (already cleaned up)
851857
if _, err := os.Stat(task.WorktreePath); os.IsNotExist(err) {
852858
// Path gone, just clear the DB reference
@@ -903,6 +909,13 @@ func (e *Executor) CleanupStaleWorktreesManual(maxAge time.Duration, dryRun bool
903909

904910
var cleaned []*db.Task
905911
for _, task := range tasks {
912+
// Skip non-worktree projects
913+
if !e.config.ProjectUsesWorktrees(task.Project) {
914+
e.db.ClearTaskWorktreePath(task.ID)
915+
cleaned = append(cleaned, task)
916+
continue
917+
}
918+
906919
// Skip if worktree path doesn't exist on disk
907920
if _, err := os.Stat(task.WorktreePath); os.IsNotExist(err) {
908921
e.db.ClearTaskWorktreePath(task.ID)
@@ -934,6 +947,10 @@ func (e *Executor) pruneAllProjectWorktrees() {
934947
return
935948
}
936949
for _, p := range projects {
950+
// Skip non-worktree projects - no git worktrees to prune
951+
if !p.UsesWorktrees() {
952+
continue
953+
}
937954
dir := e.config.GetProjectDir(p.Name)
938955
if dir == "" {
939956
continue
@@ -1919,10 +1936,10 @@ func ensureTmuxDaemon() (string, error) {
19191936
// SECURITY: workDir must be within a .task-worktrees directory to prevent Claude from
19201937
// accidentally writing to the main project directory.
19211938
func createTmuxWindow(daemonSession, windowName, workDir, script string) (string, error) {
1922-
// SECURITY: Validate that workDir is within a .task-worktrees directory
1923-
// This prevents Claude from running in the main project directory
1924-
if !isValidWorktreePath(workDir) {
1925-
return "", fmt.Errorf("security: refusing to create tmux window with workDir outside .task-worktrees: %s", workDir)
1939+
// SECURITY: Validate that workDir is within a .task-worktrees directory,
1940+
// OR is a valid project directory (for non-worktree projects).
1941+
if !isValidWorkDir(workDir) {
1942+
return "", fmt.Errorf("security: refusing to create tmux window with invalid workDir: %s", workDir)
19261943
}
19271944

19281945
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -3326,6 +3343,11 @@ func (e *Executor) setupWorktree(task *db.Task) (string, error) {
33263343
return "", fmt.Errorf("project directory not found for project: %s", task.Project)
33273344
}
33283345

3346+
// For non-worktree projects, all tasks share the project directory directly
3347+
if !e.config.ProjectUsesWorktrees(task.Project) {
3348+
return e.setupSharedWorkDir(task, projectDir, paths)
3349+
}
3350+
33293351
// Check if project is a git repo, initialize one if not
33303352
// Git is required for worktree isolation - tasks always run in worktrees
33313353
// NOTE: This should rarely happen now - git repos are initialized during project creation.
@@ -3540,6 +3562,36 @@ func (e *Executor) setupWorktree(task *db.Task) (string, error) {
35403562
return worktreePath, nil
35413563
}
35423564

3565+
// setupSharedWorkDir sets up a task to use the project directory directly,
3566+
// without git worktree isolation. Used for non-git projects.
3567+
func (e *Executor) setupSharedWorkDir(task *db.Task, projectDir string, paths claudePaths) (string, error) {
3568+
// Ensure project directory exists
3569+
if err := os.MkdirAll(projectDir, 0755); err != nil {
3570+
return "", fmt.Errorf("create project dir: %w", err)
3571+
}
3572+
3573+
// Set worktree path to the project directory itself
3574+
task.WorktreePath = projectDir
3575+
task.BranchName = "" // No branch for non-git projects
3576+
e.db.UpdateTask(task)
3577+
3578+
// Allocate a port if not already assigned
3579+
if task.Port == 0 {
3580+
port, err := e.db.AllocatePort(task.ID)
3581+
if err != nil {
3582+
e.logger.Warn("could not allocate port", "error", err)
3583+
} else {
3584+
task.Port = port
3585+
}
3586+
}
3587+
3588+
e.logLine(task.ID, "system", fmt.Sprintf("Using shared working directory: %s (no worktree isolation)", projectDir))
3589+
3590+
e.writeWorktreeEnvFile(projectDir, projectDir, task, paths.configDir)
3591+
3592+
return projectDir, nil
3593+
}
3594+
35433595
// getUserShell returns the login shell for the given username.
35443596
// On macOS it uses dscl, on Linux it reads /etc/passwd.
35453597
func getUserShell(username string) (string, error) {
@@ -4306,6 +4358,13 @@ func (e *Executor) ArchiveWorktree(task *db.Task) error {
43064358
return nil
43074359
}
43084360

4361+
// For non-worktree projects, just clear the worktree path reference.
4362+
// The shared working directory is never removed.
4363+
if !e.config.ProjectUsesWorktrees(task.Project) {
4364+
e.db.ClearTaskWorktreePath(task.ID)
4365+
return nil
4366+
}
4367+
43094368
// Get project directory to run git commands from
43104369
projectDir := e.getProjectDir(task.Project)
43114370
if projectDir == "" {
@@ -4427,6 +4486,18 @@ func (e *Executor) ArchiveWorktree(task *db.Task) error {
44274486
// 3. Runs init script
44284487
// 4. Clears the archive state from the database
44294488
func (e *Executor) UnarchiveWorktree(task *db.Task) error {
4489+
// For non-worktree projects, just restore the worktree path to the project dir
4490+
if !e.config.ProjectUsesWorktrees(task.Project) {
4491+
projectDir := e.getProjectDir(task.Project)
4492+
if projectDir == "" {
4493+
return fmt.Errorf("could not find project directory for project: %s", task.Project)
4494+
}
4495+
task.WorktreePath = projectDir
4496+
task.BranchName = ""
4497+
e.db.UpdateTask(task)
4498+
return nil
4499+
}
4500+
44304501
// Check if task has archive state
44314502
if !task.HasArchiveState() {
44324503
return fmt.Errorf("task has no archive state to restore")
@@ -5076,6 +5147,7 @@ func (e *Executor) KillPiProcess(taskID int64) bool {
50765147
// isValidWorktreePath validates that a working directory is within a .task-worktrees directory.
50775148
// This prevents Claude from accidentally writing to the main project directory.
50785149
// Returns true if the path is valid for task execution.
5150+
// isValidWorktreePath validates that a working directory is within a .task-worktrees directory.
50795151
func isValidWorktreePath(workDir string) bool {
50805152
// Empty path is never valid
50815153
if workDir == "" {
@@ -5100,6 +5172,23 @@ func isValidWorktreePath(workDir string) bool {
51005172
return strings.Contains(resolvedPath, string(filepath.Separator)+".task-worktrees"+string(filepath.Separator))
51015173
}
51025174

5175+
// isValidWorkDir validates that a working directory is either within a .task-worktrees directory
5176+
// (for git worktree projects) or is an existing directory (for non-worktree projects).
5177+
func isValidWorkDir(workDir string) bool {
5178+
// First check the traditional worktree path
5179+
if isValidWorktreePath(workDir) {
5180+
return true
5181+
}
5182+
5183+
// For non-worktree projects, the workDir is the project directory itself.
5184+
// Validate it exists and is a directory.
5185+
if workDir == "" {
5186+
return false
5187+
}
5188+
info, err := os.Stat(workDir)
5189+
return err == nil && info.IsDir()
5190+
}
5191+
51035192
// slugify converts a string to a URL/branch-friendly slug.
51045193
func slugify(s string, maxLen int) string {
51055194
// Convert to lowercase

0 commit comments

Comments
 (0)