From 1fffa8c324be837acc3d74c29cc279728f45c274 Mon Sep 17 00:00:00 2001 From: griffin Date: Thu, 17 Jul 2025 18:21:30 +0000 Subject: [PATCH 1/8] Code Formatting checkpoint: 1 --- docs/BRANCH_OFF_EXISTING_WORKTREES.md | 387 ++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 docs/BRANCH_OFF_EXISTING_WORKTREES.md diff --git a/docs/BRANCH_OFF_EXISTING_WORKTREES.md b/docs/BRANCH_OFF_EXISTING_WORKTREES.md new file mode 100644 index 00000000..daa01a22 --- /dev/null +++ b/docs/BRANCH_OFF_EXISTING_WORKTREES.md @@ -0,0 +1,387 @@ +# Branch Off Existing Worktrees Implementation Plan + +## Overview + +This document outlines the plan to implement a feature that allows users to create new worktrees by branching off existing worktrees, rather than always branching from the default base branch. + +## Current State Analysis + +### How Worktrees Are Currently Created + +The current implementation in `container/internal/services/git.go` creates worktrees only from the default branch or a specified remote branch: + +1. **CheckoutRepository** (`git.go:117-131`): Entry point for creating worktrees + - Always uses the repository's default branch or a specified remote branch + - Calls `createWorktreeForExistingRepo` with branch parameter + - Never uses existing worktrees as source + +2. **createWorktreeForExistingRepo** (`git.go:~420-450`): Creates worktree from remote branch + - Fetches branch from remote if it doesn't exist locally + - Creates worktree using `git worktree add -b ` + - Source is always a remote branch reference + +3. **createWorktreeInternalForRepo** (`git.go:~460-520`): Internal worktree creation + - Uses `git worktree add -b ` + - Source parameter is always a branch name or commit hash from remote + - Sets `SourceBranch` field in worktree model + +### Data Model + +The `Worktree` model (`container/internal/models/git.go:46-77`) contains: + +- `SourceBranch`: Currently tracks the remote branch this worktree branched from +- `Branch`: The actual branch name for this worktree +- `CommitHash`: Current commit hash +- `CommitCount`: Commits ahead of source branch + +### Frontend Implementation + +The frontend (`src/components/WorktreeRow.tsx`) displays: + +- Source branch information (line 687-689) +- Worktree actions dropdown +- Currently no option to create new worktrees from existing ones + +## Implementation Plan + +### Phase 1: Backend API Changes + +#### 1.1 Extend Worktree Creation API + +**New Endpoint**: `POST /v1/git/worktrees` + +```go +// WorktreeCreateRequest represents a request to create a new worktree +type WorktreeCreateRequest struct { + Source string `json:"source"` // Branch name, commit hash, or worktree ID + Name string `json:"name"` // User-friendly name (optional, generated if empty) + SourceType string `json:"source_type"` // "branch", "commit", or "worktree" +} +``` + +#### 1.2 Update GitService Methods + +**New Method**: `CreateWorktreeFromSource` + +```go +func (s *GitService) CreateWorktreeFromSource(repoID string, req WorktreeCreateRequest) (*models.Worktree, error) +``` + +This method will: + +1. Validate the source type and resolve the actual commit/branch +2. If source is a worktree ID, resolve to its current commit hash +3. Create the new worktree using the resolved source +4. Update the source branch tracking appropriately + +#### 1.3 Source Resolution Logic + +```go +func (s *GitService) resolveWorktreeSource(repoID string, source string, sourceType string) (resolvedSource string, sourceBranch string, err error) { + switch sourceType { + case "worktree": + // Find the worktree by ID + worktree, exists := s.worktrees[source] + if !exists { + return "", "", fmt.Errorf("worktree %s not found", source) + } + + // Use the current commit hash as source + return worktree.CommitHash, worktree.Branch, nil + + case "branch": + // Use branch name directly + return source, source, nil + + case "commit": + // Use commit hash, try to resolve parent branch + parentBranch := s.findParentBranch(repoID, source) + return source, parentBranch, nil + + default: + return "", "", fmt.Errorf("invalid source type: %s", sourceType) + } +} +``` + +### Phase 2: Frontend UI Changes + +#### 2.1 Add "Branch From This" Action + +Modify `WorktreeActionDropdown` in `src/components/WorktreeRow.tsx`: + +```tsx + onBranchFromWorktree(worktree.id, worktree.name)} +> + + Branch from this worktree + +``` + +#### 2.2 Create Branch Dialog + +New component: `src/components/BranchFromWorktreeDialog.tsx` + +```tsx +interface BranchFromWorktreeDialogProps { + open: boolean; + onClose: () => void; + sourceWorktree: Worktree; + onSubmit: (name: string) => void; +} +``` + +Features: + +- Input field for new worktree name +- Display source worktree information +- Auto-generate name based on source + timestamp +- Show preview of what will be created + +#### 2.3 Update Git State Management + +Extend `src/hooks/useGitState.ts` to support: + +- Creating worktrees from existing worktrees +- Tracking parent-child relationships +- Handling the new API endpoint + +### Phase 3: Enhanced Features + +#### 3.1 Worktree Lineage Tracking + +Enhance the data model to track worktree relationships: + +```go +type Worktree struct { + // ... existing fields ... + + // Parent worktree ID if created from another worktree + ParentWorktreeID *string `json:"parent_worktree_id,omitempty"` + + // Child worktree IDs created from this worktree + ChildWorktreeIDs []string `json:"child_worktree_ids,omitempty"` +} +``` + +#### 3.2 Visual Lineage Display + +Frontend enhancements: + +- Tree view showing worktree relationships +- Visual indicators for parent/child relationships +- Breadcrumb navigation showing lineage path + +#### 3.3 Cascade Operations + +Smart operations that consider relationships: + +- When deleting a parent worktree, offer to update children +- Sync operations that can propagate to children +- Merge operations that consider the full lineage + +### Phase 4: Advanced Scenarios + +#### 4.1 Cross-Repository Branching + +Support branching from worktrees in different repositories: + +- Validate compatibility +- Handle remote tracking differences +- Update source branch tracking appropriately + +#### 4.2 Conflict Resolution + +Enhanced conflict detection: + +- Check for conflicts when branching from worktrees +- Provide warnings about potential issues +- Suggest alternative source points + +#### 4.3 Performance Optimizations + +- Lazy loading of worktree relationships +- Efficient lineage queries +- Caching of resolved sources + +## Implementation Steps + +### Step 1: Backend Foundation + +1. Add new API endpoint for worktree creation +2. Implement source resolution logic +3. Update worktree creation to support existing worktrees as sources +4. Add proper error handling and validation + +### Step 2: Basic Frontend Integration + +1. Add "Branch from this" action to worktree dropdown +2. Create basic dialog for collecting new worktree name +3. Wire up API calls +4. Test basic functionality + +### Step 3: Enhanced UI + +1. Improve dialog with better UX +2. Add lineage tracking to data model +3. Display parent/child relationships +4. Add visual indicators + +### Step 4: Advanced Features + +1. Add cascade operations +2. Implement cross-repository support +3. Add performance optimizations +4. Comprehensive testing + +## API Design + +### New Endpoint + +``` +POST /v1/git/worktrees +Content-Type: application/json + +{ + "source": "worktree-id-123", + "source_type": "worktree", + "name": "fix-user-auth" +} +``` + +### Response + +```json +{ + "id": "new-worktree-id", + "repo_id": "owner/repo", + "name": "fix-user-auth", + "branch": "fix-user-auth", + "source_branch": "feature-login", + "parent_worktree_id": "worktree-id-123", + "commit_hash": "abc123def456", + "path": "/workspace/repo/fix-user-auth", + "created_at": "2024-01-15T14:00:00Z" +} +``` + +## Testing Strategy + +### Unit Tests + +- Source resolution logic +- Worktree creation from different source types +- Error handling for invalid sources + +### Integration Tests + +- Full workflow: create worktree from existing worktree +- API endpoint testing +- Database state validation + +### Frontend Tests + +- Dialog component behavior +- API integration +- User interaction flows + +## Migration Strategy + +### Backward Compatibility + +- Existing worktrees continue to work unchanged +- Default behavior remains the same (branch from remote) +- New feature is additive, not replacing existing functionality + +### Data Migration + +- No migration needed for existing worktrees +- New fields are optional and nullable +- Lineage tracking starts from new worktrees created after feature deployment + +## Risk Assessment + +### Technical Risks + +- **Complexity**: Adding worktree relationships increases system complexity +- **Performance**: Lineage queries could become slow with many worktrees +- **Conflicts**: Branching from uncommitted work could cause issues + +### Mitigation Strategies + +- Implement incrementally with fallbacks +- Add comprehensive validation +- Provide clear error messages +- Add performance monitoring + +### User Experience Risks + +- **Confusion**: Users might not understand the difference between source types +- **Mistakes**: Accidentally creating complex lineages + +### Mitigation Strategies + +- Clear UI labels and tooltips +- Confirmation dialogs for complex operations +- Ability to view and understand lineage + +## Success Metrics + +### Functional Metrics + +- Users can successfully create worktrees from existing worktrees +- API response times remain under 200ms +- Error rates stay below 1% + +### User Experience Metrics + +- Feature adoption rate +- User satisfaction with the workflow +- Reduction in support tickets about worktree management + +## Timeline + +### Week 1-2: Backend Implementation + +- API endpoint development +- Source resolution logic +- Basic testing + +### Week 3-4: Frontend Integration + +- UI components +- API integration +- Basic user testing + +### Week 5-6: Enhanced Features + +- Lineage tracking +- Visual improvements +- Advanced testing + +### Week 7-8: Polish and Launch + +- Performance optimization +- Documentation +- Production deployment + +## Future Enhancements + +### Potential Features + +- Worktree templates for common branching patterns +- Automated cleanup of deep lineage chains +- Integration with PR workflows +- Branching policies and restrictions + +### Long-term Vision + +- Full worktree lifecycle management +- Advanced collaboration features +- Integration with CI/CD pipelines +- Analytics and insights about branching patterns + +--- + +_This document will be updated as the implementation progresses and requirements evolve._ From ae76dba7958608cfd2d4b22f7bcdadefdddb42b8 Mon Sep 17 00:00:00 2001 From: griffin Date: Thu, 17 Jul 2025 18:28:11 +0000 Subject: [PATCH 2/8] Project Start --- container/internal/handlers/git.go | 46 ++++++++++++++++++++++++++++++ container/internal/models/git.go | 9 ++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/container/internal/handlers/git.go b/container/internal/handlers/git.go index f6266af2..f05fd1db 100644 --- a/container/internal/handlers/git.go +++ b/container/internal/handlers/git.go @@ -547,3 +547,49 @@ func (h *GitHandler) GetPullRequestInfo(c *fiber.Ctx) error { return c.JSON(prInfo) } + +// CreateWorktree creates a new worktree from various source types +// @Summary Create a new worktree +// @Description Creates a new worktree from a branch, commit, or existing worktree +// @Tags git +// @Accept json +// @Produce json +// @Param request body models.WorktreeCreateRequest true "Worktree creation request" +// @Success 201 {object} models.Worktree +// @Router /v1/git/worktrees [post] +func (h *GitHandler) CreateWorktree(c *fiber.Ctx) error { + var req models.WorktreeCreateRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": "Invalid request body", + }) + } + + // Validate request + if req.Source == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "Source is required", + }) + } + + // Default source type to branch if not specified + if req.SourceType == "" { + req.SourceType = "branch" + } + + // Validate source type + if req.SourceType != "branch" && req.SourceType != "commit" && req.SourceType != "worktree" { + return c.Status(400).JSON(fiber.Map{ + "error": "Source type must be 'branch', 'commit', or 'worktree'", + }) + } + + worktree, err := h.gitService.CreateWorktreeFromSource(req) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.Status(201).JSON(worktree) +} diff --git a/container/internal/models/git.go b/container/internal/models/git.go index 9ff21c5d..3f434dc6 100644 --- a/container/internal/models/git.go +++ b/container/internal/models/git.go @@ -74,12 +74,17 @@ type Worktree struct { SessionTitle *TitleEntry `json:"session_title,omitempty"` // History of session titles SessionTitleHistory []TitleEntry `json:"session_title_history,omitempty"` + // Parent worktree ID if created from another worktree + ParentWorktreeID *string `json:"parent_worktree_id,omitempty"` + // Child worktree IDs created from this worktree + ChildWorktreeIDs []string `json:"child_worktree_ids,omitempty"` } // WorktreeCreateRequest represents a request to create a new worktree type WorktreeCreateRequest struct { - Source string `json:"source"` // Branch name or commit hash - Name string `json:"name"` // User-friendly name + Source string `json:"source"` // Branch name, commit hash, or worktree ID + Name string `json:"name"` // User-friendly name (optional, generated if empty) + SourceType string `json:"source_type"` // "branch", "commit", or "worktree" } // CheckoutRequest represents a request to checkout a repository From a8b47bde7e968fee23d54bcad6bb6fc2f134c0ba Mon Sep 17 00:00:00 2001 From: griffin Date: Thu, 17 Jul 2025 18:33:35 +0000 Subject: [PATCH 3/8] Branch Documentation checkpoint: 1 --- container/docs/docs.go | 60 +++++++++++++ container/docs/swagger.json | 60 +++++++++++++ container/docs/swagger.yaml | 41 +++++++++ container/internal/services/git.go | 139 +++++++++++++++++++++++++++++ 4 files changed, 300 insertions(+) diff --git a/container/docs/docs.go b/container/docs/docs.go index f0f14dfb..b00846a5 100644 --- a/container/docs/docs.go +++ b/container/docs/docs.go @@ -346,6 +346,38 @@ const docTemplate = `{ } } } + }, + "post": { + "description": "Creates a new worktree from a branch, commit, or existing worktree", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "git" + ], + "summary": "Create a new worktree", + "parameters": [ + { + "description": "Worktree creation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.WorktreeCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Worktree" + } + } + } } }, "/v1/git/worktrees/{id}": { @@ -1413,6 +1445,13 @@ const docTemplate = `{ "type": "string", "example": "feature/api-docs" }, + "child_worktree_ids": { + "description": "Child worktree IDs created from this worktree", + "type": "array", + "items": { + "type": "string" + } + }, "commit_count": { "description": "Number of commits ahead of the divergence point (CommitHash)", "type": "integer", @@ -1453,6 +1492,10 @@ const docTemplate = `{ "type": "string", "example": "feature-api-docs" }, + "parent_worktree_id": { + "description": "Parent worktree ID if created from another worktree", + "type": "string" + }, "path": { "description": "Absolute path to the worktree directory", "type": "string", @@ -1485,6 +1528,23 @@ const docTemplate = `{ } } }, + "github_com_vanpelt_catnip_internal_models.WorktreeCreateRequest": { + "type": "object", + "properties": { + "name": { + "description": "User-friendly name (optional, generated if empty)", + "type": "string" + }, + "source": { + "description": "Branch name, commit hash, or worktree ID", + "type": "string" + }, + "source_type": { + "description": "\"branch\", \"commit\", or \"worktree\"", + "type": "string" + } + } + }, "github_com_vanpelt_catnip_internal_services.ServiceInfo": { "type": "object", "properties": { diff --git a/container/docs/swagger.json b/container/docs/swagger.json index e1956d5e..97f6de8a 100644 --- a/container/docs/swagger.json +++ b/container/docs/swagger.json @@ -343,6 +343,38 @@ } } } + }, + "post": { + "description": "Creates a new worktree from a branch, commit, or existing worktree", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "git" + ], + "summary": "Create a new worktree", + "parameters": [ + { + "description": "Worktree creation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.WorktreeCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Worktree" + } + } + } } }, "/v1/git/worktrees/{id}": { @@ -1410,6 +1442,13 @@ "type": "string", "example": "feature/api-docs" }, + "child_worktree_ids": { + "description": "Child worktree IDs created from this worktree", + "type": "array", + "items": { + "type": "string" + } + }, "commit_count": { "description": "Number of commits ahead of the divergence point (CommitHash)", "type": "integer", @@ -1450,6 +1489,10 @@ "type": "string", "example": "feature-api-docs" }, + "parent_worktree_id": { + "description": "Parent worktree ID if created from another worktree", + "type": "string" + }, "path": { "description": "Absolute path to the worktree directory", "type": "string", @@ -1482,6 +1525,23 @@ } } }, + "github_com_vanpelt_catnip_internal_models.WorktreeCreateRequest": { + "type": "object", + "properties": { + "name": { + "description": "User-friendly name (optional, generated if empty)", + "type": "string" + }, + "source": { + "description": "Branch name, commit hash, or worktree ID", + "type": "string" + }, + "source_type": { + "description": "\"branch\", \"commit\", or \"worktree\"", + "type": "string" + } + } + }, "github_com_vanpelt_catnip_internal_services.ServiceInfo": { "type": "object", "properties": { diff --git a/container/docs/swagger.yaml b/container/docs/swagger.yaml index 9ffac657..a49fc172 100644 --- a/container/docs/swagger.yaml +++ b/container/docs/swagger.yaml @@ -335,6 +335,11 @@ definitions: description: Current git branch name in this worktree example: feature/api-docs type: string + child_worktree_ids: + description: Child worktree IDs created from this worktree + items: + type: string + type: array commit_count: description: Number of commits ahead of the divergence point (CommitHash) example: 3 @@ -369,6 +374,9 @@ definitions: description: User-friendly name for this worktree (e.g., 'vectorize-quasar') example: feature-api-docs type: string + parent_worktree_id: + description: Parent worktree ID if created from another worktree + type: string path: description: Absolute path to the worktree directory example: /workspace/worktrees/feature-api-docs @@ -391,6 +399,18 @@ definitions: example: main type: string type: object + github_com_vanpelt_catnip_internal_models.WorktreeCreateRequest: + properties: + name: + description: User-friendly name (optional, generated if empty) + type: string + source: + description: Branch name, commit hash, or worktree ID + type: string + source_type: + description: '"branch", "commit", or "worktree"' + type: string + type: object github_com_vanpelt_catnip_internal_services.ServiceInfo: properties: command: @@ -933,6 +953,27 @@ paths: summary: List all worktrees tags: - git + post: + consumes: + - application/json + description: Creates a new worktree from a branch, commit, or existing worktree + parameters: + - description: Worktree creation request + in: body + name: request + required: true + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.WorktreeCreateRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.Worktree' + summary: Create a new worktree + tags: + - git /v1/git/worktrees/{id}: delete: description: Removes a worktree from the repository diff --git a/container/internal/services/git.go b/container/internal/services/git.go index ec26e7b4..a0034247 100644 --- a/container/internal/services/git.go +++ b/container/internal/services/git.go @@ -2045,6 +2045,145 @@ func (s *GitService) unshallowRepository(barePath, branch string) { } } +// CreateWorktreeFromSource creates a new worktree from various source types +func (s *GitService) CreateWorktreeFromSource(req models.WorktreeCreateRequest) (*models.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Find the repository for this worktree + var repo *models.Repository + var repoID string + + // If source is a worktree, get its repository + if req.SourceType == "worktree" { + sourceWorktree, exists := s.worktrees[req.Source] + if !exists { + return nil, fmt.Errorf("source worktree %s not found", req.Source) + } + repoID = sourceWorktree.RepoID + repo = s.repositories[repoID] + if repo == nil { + return nil, fmt.Errorf("repository %s not found", repoID) + } + } else { + // For branch/commit sources, find the repository + // For now, assume we're working with the first available repository + // This could be enhanced to support multi-repo scenarios + if len(s.repositories) == 0 { + return nil, fmt.Errorf("no repositories available") + } + for id, r := range s.repositories { + repo = r + repoID = id + break + } + } + + // Resolve the source to an actual git reference + resolvedSource, sourceBranch, parentWorktreeID, err := s.resolveWorktreeSource(repoID, req.Source, req.SourceType) + if err != nil { + return nil, fmt.Errorf("failed to resolve source: %v", err) + } + + // Generate name if not provided + name := req.Name + if name == "" { + name = generateSessionName() + } + + // Create the worktree + worktree, err := s.createWorktreeInternalForRepo(repo, resolvedSource, name, false) + if err != nil { + return nil, fmt.Errorf("failed to create worktree: %v", err) + } + + // Update source branch and parent relationship + worktree.SourceBranch = sourceBranch + worktree.ParentWorktreeID = parentWorktreeID + + // Update parent worktree's children list + if parentWorktreeID != nil { + if parentWorktree, exists := s.worktrees[*parentWorktreeID]; exists { + if parentWorktree.ChildWorktreeIDs == nil { + parentWorktree.ChildWorktreeIDs = make([]string, 0) + } + parentWorktree.ChildWorktreeIDs = append(parentWorktree.ChildWorktreeIDs, worktree.ID) + } + } + + // Store the worktree + s.worktrees[worktree.ID] = worktree + + // Save state + _ = s.saveState() + + log.Printf("✅ Worktree created from source: %s (type: %s)", req.Source, req.SourceType) + return worktree, nil +} + +// resolveWorktreeSource resolves a source reference to an actual git reference +func (s *GitService) resolveWorktreeSource(repoID string, source string, sourceType string) (resolvedSource string, sourceBranch string, parentWorktreeID *string, err error) { + switch sourceType { + case "worktree": + // Find the worktree by ID + worktree, exists := s.worktrees[source] + if !exists { + return "", "", nil, fmt.Errorf("worktree %s not found", source) + } + + // Use the current commit hash as source + return worktree.CommitHash, worktree.Branch, &source, nil + + case "branch": + // Use branch name directly + return source, source, nil, nil + + case "commit": + // Use commit hash, try to resolve parent branch + parentBranch := s.findParentBranch(repoID, source) + return source, parentBranch, nil, nil + + default: + return "", "", nil, fmt.Errorf("invalid source type: %s", sourceType) + } +} + +// findParentBranch tries to find which branch contains the given commit +func (s *GitService) findParentBranch(repoID string, commitHash string) string { + repo := s.repositories[repoID] + if repo == nil { + return "main" // fallback + } + + // Try to find which branch contains this commit + cmd := exec.Command("git", "-C", repo.Path, "branch", "--contains", commitHash) + output, err := cmd.Output() + if err != nil { + return "main" // fallback + } + + branches := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, branch := range branches { + cleanBranch := strings.TrimSpace(branch) + cleanBranch = strings.TrimPrefix(cleanBranch, "*") + cleanBranch = strings.TrimPrefix(cleanBranch, "+") + cleanBranch = strings.TrimSpace(cleanBranch) + cleanBranch = strings.TrimPrefix(cleanBranch, "origin/") + + // Skip preview branches + if strings.HasPrefix(cleanBranch, "preview/") { + continue + } + + // Return the first valid branch + if cleanBranch != "" { + return cleanBranch + } + } + + return "main" // fallback +} + // GetRepositoryByID returns a repository by its ID func (s *GitService) GetRepositoryByID(repoID string) *models.Repository { s.mu.RLock() From 3f84c963f4d9c485b918aa9bcfcce895b026c722 Mon Sep 17 00:00:00 2001 From: griffin Date: Thu, 17 Jul 2025 18:35:05 +0000 Subject: [PATCH 4/8] Branch Documentation checkpoint: 2 --- src/components/WorktreeRow.tsx | 10 ++ src/routes/git.tsx | 286 +++++++++++++++++++-------------- 2 files changed, 172 insertions(+), 124 deletions(-) diff --git a/src/components/WorktreeRow.tsx b/src/components/WorktreeRow.tsx index fc282ecd..686eb3ab 100644 --- a/src/components/WorktreeRow.tsx +++ b/src/components/WorktreeRow.tsx @@ -15,6 +15,7 @@ import { ChevronDown, Eye, FileText, + GitBranch, GitMerge, MoreHorizontal, RefreshCw, @@ -68,6 +69,7 @@ interface WorktreeRowProps { isDirty: boolean, commitCount: number, ) => void; + onBranchFromWorktree: (worktreeId: string, name: string) => void; } interface CommitHashDisplayProps { @@ -277,6 +279,13 @@ function WorktreeActionDropdown({ Sync with {worktree.source_branch} + onBranchFromWorktree(worktree.id, worktree.name)} + > + + Branch from this worktree + + @@ -624,6 +633,7 @@ export function WorktreeRow({ onMerge, onCreatePreview, onConfirmDelete, + onBranchFromWorktree, prStatuses, repositories, }: WorktreeRowPropsWithPR) { diff --git a/src/routes/git.tsx b/src/routes/git.tsx index 6f0c311b..d98c381a 100644 --- a/src/routes/git.tsx +++ b/src/routes/git.tsx @@ -15,14 +15,13 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ErrorAlert } from "@/components/ErrorAlert"; import { WorktreeRow } from "@/components/WorktreeRow"; import { PullRequestDialog } from "@/components/PullRequestDialog"; -import { - GitBranch, - Copy, - RefreshCw, - Loader2, -} from "lucide-react"; +import { GitBranch, Copy, RefreshCw, Loader2 } from "lucide-react"; import { toast } from "sonner"; -import { copyRemoteCommand, showPreviewToast, parseGitUrl } from "@/lib/git-utils"; +import { + copyRemoteCommand, + showPreviewToast, + parseGitUrl, +} from "@/lib/git-utils"; import { gitApi, type LocalRepository } from "@/lib/git-api"; import { useGitState } from "@/hooks/useGitState"; @@ -50,7 +49,9 @@ function GitPage() { } = useGitState(); const [githubUrl, setGithubUrl] = useState(""); - const [openDiffWorktreeId, setOpenDiffWorktreeId] = useState(null); + const [openDiffWorktreeId, setOpenDiffWorktreeId] = useState( + null, + ); const [confirmDialog, setConfirmDialog] = useState<{ open: boolean; title: string; @@ -97,7 +98,7 @@ function GitPage() { setErrorAlert({ open: true, title: "Invalid URL", - description: `Unknown repository URL format: ${url}` + description: `Unknown repository URL format: ${url}`, }); return; } @@ -106,20 +107,21 @@ function GitPage() { const response = await fetch(`/v1/git/checkout/${org}/${repo}`, { method: "POST", }); - + if (response.ok) { await refreshAll(); - const message = parsedUrl.type === "local" - ? "Local repository checked out successfully" - : "Repository checked out successfully"; + const message = + parsedUrl.type === "local" + ? "Local repository checked out successfully" + : "Repository checked out successfully"; toast.success(message); } else { - const errorData = await response.json() as { error?: string }; + const errorData = (await response.json()) as { error?: string }; console.error("Failed to checkout repository:", errorData); setErrorAlert({ open: true, title: "Checkout Failed", - description: `Failed to checkout repository: ${errorData.error ?? 'Unknown error'}` + description: `Failed to checkout repository: ${errorData.error ?? "Unknown error"}`, }); } } catch (error) { @@ -127,7 +129,7 @@ function GitPage() { setErrorAlert({ open: true, title: "Checkout Failed", - description: `Failed to checkout repository: ${String(error)}` + description: `Failed to checkout repository: ${String(error)}`, }); } finally { setLoading(false); @@ -153,8 +155,14 @@ function GitPage() { } }; - const mergeWorktreeToMain = async (id: string, worktreeName: string, squash = true) => { - const success = await gitApi.mergeWorktree(id, worktreeName, squash, { setErrorAlert }); + const mergeWorktreeToMain = async ( + id: string, + worktreeName: string, + squash = true, + ) => { + const success = await gitApi.mergeWorktree(id, worktreeName, squash, { + setErrorAlert, + }); if (success) { await fetchWorktrees(); await fetchGitStatus(); @@ -170,19 +178,21 @@ function GitPage() { }; const toggleDiff = (worktreeId: string) => { - setOpenDiffWorktreeId(prev => prev === worktreeId ? null : worktreeId); + setOpenDiffWorktreeId((prev) => (prev === worktreeId ? null : worktreeId)); }; const onMerge = (id: string, name: string) => { const hasConflicts = mergeConflicts[id]?.has_conflicts ?? false; - const conflictFilesString = mergeConflicts[id]?.conflict_files?.join(", ") ?? `${mergeConflicts[id]?.conflict_files?.length} files`; - const worktree = worktrees.find(wt => wt.id === id); + const conflictFilesString = + mergeConflicts[id]?.conflict_files?.join(", ") ?? + `${mergeConflicts[id]?.conflict_files?.length} files`; + const worktree = worktrees.find((wt) => wt.id === id); const commitCount = worktree?.commit_count ?? 0; const sourceBranch = worktree?.source_branch ?? ""; const description = ` Merge ${commitCount} commits from "${name}" back to the ${sourceBranch} branch? This will make your changes available outside the container. ${hasConflicts ? `⚠️ Warning: This merge will cause conflicts in ${conflictFilesString}. Merge ${commitCount} commits from "${name}" back to the ${sourceBranch} branch anyway?` : ""} - ` + `; setConfirmDialog({ open: true, title: "Merge to Main", @@ -190,13 +200,18 @@ function GitPage() { onConfirm: () => void mergeWorktreeToMain(id, name), variant: hasConflicts ? "destructive" : "default", }); - } + }; - const onConfirmDelete = (id: string, name: string, isDirty: boolean, commitCount: number) => { + const onConfirmDelete = ( + id: string, + name: string, + isDirty: boolean, + commitCount: number, + ) => { const changesList = []; if (isDirty) changesList.push("uncommitted changes"); if (commitCount > 0) changesList.push(`${commitCount} commits`); - + setConfirmDialog({ open: true, title: "Delete Worktree", @@ -204,7 +219,7 @@ function GitPage() { onConfirm: () => void deleteWorktree(id), variant: "destructive", }); - } + }; return (
@@ -222,35 +237,52 @@ function GitPage() { - {loading ? (
- -
) : (<> - {worktrees.length > 0 ? ( -
- {worktrees.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()).map((worktree) => ( - void syncWorktree(worktree.id)} - onMerge={onMerge} - onCreatePreview={() => void createWorktreePreview(worktree.id, worktree.branch)} - prStatuses={prStatuses} - onConfirmDelete={onConfirmDelete} - repositories={gitStatus.repositories} - /> - ))} + {loading ? ( +
+
) : ( -

No worktrees found

- )})} + <> + {worktrees.length > 0 ? ( +
+ {worktrees + .sort( + (a, b) => + new Date(a.created_at).getTime() - + new Date(b.created_at).getTime(), + ) + .map((worktree) => ( + void syncWorktree(worktree.id)} + onMerge={onMerge} + onCreatePreview={() => + void createWorktreePreview( + worktree.id, + worktree.branch, + ) + } + prStatuses={prStatuses} + onConfirmDelete={onConfirmDelete} + onBranchFromWorktree={onBranchFromWorktree} + repositories={gitStatus.repositories} + /> + ))} +
+ ) : ( +

No worktrees found

+ )} + + )} @@ -282,7 +314,7 @@ function GitPage() {
0 ? (
- {Object.values(gitStatus.repositories).map((repo: LocalRepository) => ( -
-
-

{repo.id}

- {repoBranches[repo.id] && - repoBranches[repo.id].length > 0 && ( - <> - {(() => { - // For local repos, only show branches that have worktrees - let branchesToShow = repoBranches[repo.id]; - if (repo.id.startsWith("local/")) { - const worktreeBranches = worktrees - .filter(wt => wt.repo_id === repo.id) - .map(wt => wt.source_branch); - branchesToShow = repoBranches[repo.id].filter(branch => - worktreeBranches.includes(branch) - ); - } - - return branchesToShow.map((branch) => ( - { - if (!repo.id.startsWith("local/")) { - window.open( - `${repo.url}/tree/${branch}`, - "_blank" - ) - } - }} - > - {branch} - - )); - })()} - - )} -
- {!repo.id.startsWith("local/") && ( -
-
- - git remote add catnip {window.location.origin}/ - {repo.id.split("/")[1]}.git - - -
+ {Object.values(gitStatus.repositories).map( + (repo: LocalRepository) => ( +
+
+

{repo.id}

+ {repoBranches[repo.id] && + repoBranches[repo.id].length > 0 && ( + <> + {(() => { + // For local repos, only show branches that have worktrees + let branchesToShow = repoBranches[repo.id]; + if (repo.id.startsWith("local/")) { + const worktreeBranches = worktrees + .filter((wt) => wt.repo_id === repo.id) + .map((wt) => wt.source_branch); + branchesToShow = repoBranches[repo.id].filter( + (branch) => worktreeBranches.includes(branch), + ); + } + + return branchesToShow.map((branch) => ( + { + if (!repo.id.startsWith("local/")) { + window.open( + `${repo.url}/tree/${branch}`, + "_blank", + ); + } + }} + > + {branch} + + )); + })()} + + )}
- )} -
- ))} + {!repo.id.startsWith("local/") && ( +
+
+ + git remote add catnip {window.location.origin}/ + {repo.id.split("/")[1]}.git + + +
+
+ )} +
+ ), + )}

Total repositories:{" "} @@ -410,18 +444,20 @@ function GitPage() { {/* Confirmation Dialog */} setConfirmDialog(prev => ({ ...prev, open }))} + onOpenChange={(open) => setConfirmDialog((prev) => ({ ...prev, open }))} title={confirmDialog.title} description={confirmDialog.description} onConfirm={confirmDialog.onConfirm} variant={confirmDialog.variant} - confirmText={confirmDialog.variant === "destructive" ? "Delete" : "Continue"} + confirmText={ + confirmDialog.variant === "destructive" ? "Delete" : "Continue" + } /> {/* Error Alert */} setErrorAlert(prev => ({ ...prev, open }))} + onOpenChange={(open) => setErrorAlert((prev) => ({ ...prev, open }))} title={errorAlert.title} description={errorAlert.description} /> @@ -429,14 +465,16 @@ function GitPage() { {/* Pull Request Dialog */} setPrDialog(prev => ({ ...prev, open }))} + onOpenChange={(open) => setPrDialog((prev) => ({ ...prev, open }))} worktreeId={prDialog.worktreeId} branchName={prDialog.branchName} title={prDialog.title} description={prDialog.description} isUpdate={prDialog.isUpdate} - onTitleChange={(title) => setPrDialog(prev => ({ ...prev, title }))} - onDescriptionChange={(description) => setPrDialog(prev => ({ ...prev, description }))} + onTitleChange={(title) => setPrDialog((prev) => ({ ...prev, title }))} + onDescriptionChange={(description) => + setPrDialog((prev) => ({ ...prev, description })) + } onRefreshPrStatuses={fetchPrStatuses} />

From 16541b4344310ae9e3c0af268e6eb5dcfa18f104 Mon Sep 17 00:00:00 2001 From: griffin Date: Thu, 17 Jul 2025 18:36:35 +0000 Subject: [PATCH 5/8] Branch Documentation checkpoint: 3 --- src/routes/git.tsx | 128 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/routes/git.tsx b/src/routes/git.tsx index d98c381a..81cfd7b9 100644 --- a/src/routes/git.tsx +++ b/src/routes/git.tsx @@ -10,6 +10,15 @@ import { } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { RepoSelector } from "@/components/RepoSelector"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ErrorAlert } from "@/components/ErrorAlert"; @@ -90,6 +99,13 @@ function GitPage() { isUpdate: false, }); + const [branchDialog, setBranchDialog] = useState({ + open: false, + sourceWorktreeId: "", + sourceWorktreeName: "", + newWorktreeName: "", + }); + const handleCheckout = async (url: string) => { setLoading(true); try { @@ -221,6 +237,62 @@ function GitPage() { }); }; + const onBranchFromWorktree = (worktreeId: string, name: string) => { + setBranchDialog({ + open: true, + sourceWorktreeId: worktreeId, + sourceWorktreeName: name, + newWorktreeName: "", + }); + }; + + const createWorktreeFromSource = async ( + sourceWorktreeId: string, + newName: string, + ) => { + try { + setLoading(true); + const response = await fetch("/v1/git/worktrees", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + source: sourceWorktreeId, + source_type: "worktree", + name: newName, + }), + }); + + if (response.ok) { + await refreshAll(); + toast.success(`Worktree "${newName}" created successfully`); + setBranchDialog({ + open: false, + sourceWorktreeId: "", + sourceWorktreeName: "", + newWorktreeName: "", + }); + } else { + const errorData = (await response.json()) as { error?: string }; + setErrorAlert({ + open: true, + title: "Failed to Create Worktree", + description: errorData.error || "Unknown error occurred", + }); + } + } catch (error) { + console.error("Failed to create worktree:", error); + setErrorAlert({ + open: true, + title: "Failed to Create Worktree", + description: String(error), + }); + } finally { + setLoading(false); + } + }; + return (
@@ -441,6 +513,62 @@ function GitPage() { + {/* Branch From Worktree Dialog */} + setBranchDialog((prev) => ({ ...prev, open }))} + > + + + Create New Worktree + + Create a new worktree from "{branchDialog.sourceWorktreeName}". + This will branch off the current commit in the source worktree. + + +
+
+ + + setBranchDialog((prev) => ({ + ...prev, + newWorktreeName: e.target.value, + })) + } + className="col-span-3" + placeholder="Enter worktree name (leave empty for auto-generated)" + /> +
+
+ + + + +
+
+ {/* Confirmation Dialog */} Date: Thu, 17 Jul 2025 18:38:05 +0000 Subject: [PATCH 6/8] Branch Documentation checkpoint: 4 --- src/components/WorktreeRow.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/WorktreeRow.tsx b/src/components/WorktreeRow.tsx index 686eb3ab..16fe5645 100644 --- a/src/components/WorktreeRow.tsx +++ b/src/components/WorktreeRow.tsx @@ -245,6 +245,7 @@ interface WorktreeActionDropdownProps { commitCount: number, ) => void; onOpenPrDialog: (worktreeId: string, branchName: string) => void; + onBranchFromWorktree: (worktreeId: string, name: string) => void; } function WorktreeActionDropdown({ @@ -256,6 +257,7 @@ function WorktreeActionDropdown({ onCreatePreview, onConfirmDelete, onOpenPrDialog, + onBranchFromWorktree, }: WorktreeActionDropdownProps) { const handleDeleteClick = () => { onConfirmDelete( @@ -609,6 +611,7 @@ function WorktreeActions({ onCreatePreview={onCreatePreview} onConfirmDelete={onConfirmDelete} onOpenPrDialog={onOpenPrDialog} + onBranchFromWorktree={handleBranchFromWorktree} />
); @@ -677,6 +680,10 @@ export function WorktreeRow({ }); }; + const handleBranchFromWorktree = (worktreeId: string, name: string) => { + onBranchFromWorktree(worktreeId, name); + }; + // const totalAdditions = diffStat?.file_diffs?.filter(diff => diff.change_type === 'added').length ?? 0; // const totalDeletions = diffStat?.file_diffs?.filter(diff => diff.change_type === 'deleted').length ?? 0; From 1a73259a9e10db16560b6e04ced5932feb108949 Mon Sep 17 00:00:00 2001 From: griffin Date: Thu, 17 Jul 2025 19:02:24 +0000 Subject: [PATCH 7/8] Branch Documentation checkpoint: 1 --- container/cmd/server/main.go | 1 + container/internal/assets/embedded.go | 4 +- container/internal/cmd/logs.go | 10 +- container/internal/cmd/root.go | 22 ++-- container/internal/handlers/auth.go | 33 +++-- container/internal/handlers/ports.go | 10 +- container/internal/handlers/sessions.go | 44 +++---- container/internal/handlers/upload.go | 14 +-- container/internal/models/settings.go | 130 ++++++++++---------- container/internal/services/commit_sync.go | 22 ++-- container/internal/services/git_http.go | 62 +++++----- container/internal/tui/pty_client.go | 16 +-- container/internal/tui/shell_manager.go | 1 - container/internal/tui/terminal_emulator.go | 34 +++-- src/components/WorktreeRow.tsx | 5 +- 15 files changed, 204 insertions(+), 204 deletions(-) diff --git a/container/cmd/server/main.go b/container/cmd/server/main.go index 717b1bf4..38f4f500 100644 --- a/container/cmd/server/main.go +++ b/container/cmd/server/main.go @@ -125,6 +125,7 @@ func main() { v1.Post("/git/checkout/:org/:repo", gitHandler.CheckoutRepository) v1.Get("/git/status", gitHandler.GetStatus) v1.Get("/git/worktrees", gitHandler.ListWorktrees) + v1.Post("/git/worktrees", gitHandler.CreateWorktree) v1.Delete("/git/worktrees/:id", gitHandler.DeleteWorktree) v1.Post("/git/worktrees/:id/sync", gitHandler.SyncWorktree) v1.Get("/git/worktrees/:id/sync/check", gitHandler.CheckSyncConflicts) diff --git a/container/internal/assets/embedded.go b/container/internal/assets/embedded.go index 64b20c21..3e960dc9 100644 --- a/container/internal/assets/embedded.go +++ b/container/internal/assets/embedded.go @@ -16,7 +16,7 @@ func GetEmbeddedAssets() fs.FS { // Assets not embedded (likely development build) return nil } - + assets, err := fs.Sub(embeddedAssets, "dist") if err != nil { // This shouldn't happen if ReadDir succeeded above @@ -29,4 +29,4 @@ func GetEmbeddedAssets() fs.FS { func HasEmbeddedAssets() bool { _, err := embeddedAssets.ReadDir("dist") return err == nil -} \ No newline at end of file +} diff --git a/container/internal/cmd/logs.go b/container/internal/cmd/logs.go index 9daedd36..fda10de7 100644 --- a/container/internal/cmd/logs.go +++ b/container/internal/cmd/logs.go @@ -85,13 +85,13 @@ func runWithLogging(args []string) error { // Set up the command command := exec.Command(args[0], args[1:]...) - + // Create pipes for stdout and stderr stdout, err := command.StdoutPipe() if err != nil { return fmt.Errorf("failed to create stdout pipe: %w", err) } - + stderr, err := command.StderrPipe() if err != nil { return fmt.Errorf("failed to create stderr pipe: %w", err) @@ -114,7 +114,7 @@ func runWithLogging(args []string) error { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() - fmt.Println(line) // Print to console + fmt.Println(line) // Print to console if _, err := fmt.Fprintln(file, line); err != nil { fmt.Fprintf(os.Stderr, "Error writing to log file: %v\n", err) } @@ -125,7 +125,7 @@ func runWithLogging(args []string) error { scanner := bufio.NewScanner(stderr) for scanner.Scan() { line := scanner.Text() - fmt.Fprintln(os.Stderr, line) // Print to console stderr + fmt.Fprintln(os.Stderr, line) // Print to console stderr if _, err := fmt.Fprintln(file, line); err != nil { fmt.Fprintf(os.Stderr, "Error writing to log file: %v\n", err) } @@ -156,4 +156,4 @@ func runWithLogging(args []string) error { func init() { rootCmd.AddCommand(logsCmd) -} \ No newline at end of file +} diff --git a/container/internal/cmd/root.go b/container/internal/cmd/root.go index 674e3f21..1f881482 100644 --- a/container/internal/cmd/root.go +++ b/container/internal/cmd/root.go @@ -58,10 +58,10 @@ func Execute() { func init() { rootCmd.CompletionOptions.DisableDefaultCmd = true - + // Add version command rootCmd.AddCommand(versionCmd) - + // Set custom help function to use Glow for beautiful markdown rendering rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { renderMarkdownHelp(cmd) @@ -91,7 +91,7 @@ var versionCmd = &cobra.Command{ func renderMarkdownHelp(cmd *cobra.Command) { // Create the help content var helpContent strings.Builder - + // Add the long description if available if cmd.Long != "" { helpContent.WriteString(cmd.Long) @@ -100,13 +100,13 @@ func renderMarkdownHelp(cmd *cobra.Command) { helpContent.WriteString("# " + cmd.Short) helpContent.WriteString("\n\n") } - + // Add usage helpContent.WriteString("## 📖 Usage\n\n") helpContent.WriteString("```bash\n") helpContent.WriteString(cmd.UseLine()) helpContent.WriteString("\n```\n\n") - + // Add available commands if cmd.HasAvailableSubCommands() { helpContent.WriteString("## 🔧 Available Commands\n\n") @@ -117,7 +117,7 @@ func renderMarkdownHelp(cmd *cobra.Command) { } helpContent.WriteString("\n") } - + // Add flags if cmd.HasAvailableFlags() { helpContent.WriteString("## ⚙️ Flags\n\n") @@ -128,7 +128,7 @@ func renderMarkdownHelp(cmd *cobra.Command) { helpContent.WriteString("```\n\n") } } - + // Add global flags if this is a subcommand if cmd.HasParent() && cmd.InheritedFlags().HasFlags() { helpContent.WriteString("## 🌐 Global Flags\n\n") @@ -139,7 +139,7 @@ func renderMarkdownHelp(cmd *cobra.Command) { helpContent.WriteString("```\n\n") } } - + // Render with glamour renderer, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), @@ -150,13 +150,13 @@ func renderMarkdownHelp(cmd *cobra.Command) { _ = cmd.Help() return } - + rendered, err := renderer.Render(helpContent.String()) if err != nil { // Fallback to default help if rendering fails _ = cmd.Help() return } - + fmt.Print(rendered) -} \ No newline at end of file +} diff --git a/container/internal/handlers/auth.go b/container/internal/handlers/auth.go index 267f29aa..bdf8d546 100644 --- a/container/internal/handlers/auth.go +++ b/container/internal/handlers/auth.go @@ -17,38 +17,38 @@ import ( // AuthHandler handles authentication flows type AuthHandler struct { - activeAuth *AuthProcess - authMutex sync.Mutex + activeAuth *AuthProcess + authMutex sync.Mutex } // AuthProcess represents an active authentication process type AuthProcess struct { - Cmd *exec.Cmd - Code string - URL string - Status string // "pending", "waiting", "success", "error" - Error string - StartedAt time.Time + Cmd *exec.Cmd + Code string + URL string + Status string // "pending", "waiting", "success", "error" + Error string + StartedAt time.Time } // AuthStartResponse represents the auth start response // @Description Response when starting GitHub device flow authentication type AuthStartResponse struct { // Device verification code to enter on GitHub - Code string `json:"code" example:"1234-5678"` + Code string `json:"code" example:"1234-5678"` // GitHub device activation URL - URL string `json:"url" example:"https://github.com/login/device"` + URL string `json:"url" example:"https://github.com/login/device"` // Current authentication status Status string `json:"status" example:"waiting"` } -// AuthStatusResponse represents the auth status response +// AuthStatusResponse represents the auth status response // @Description Response containing the current authentication status type AuthStatusResponse struct { // Authentication status: pending, waiting, success, or error Status string `json:"status" example:"success"` // Error message if authentication failed - Error string `json:"error,omitempty" example:"authentication timeout"` + Error string `json:"error,omitempty" example:"authentication timeout"` } // NewAuthHandler creates a new auth handler @@ -78,7 +78,7 @@ func (h *AuthHandler) StartGitHubAuth(c *fiber.Ctx) error { "HOME=/home/catnip", "USER=catnip", ) - + // Set stdin to null to avoid hanging on prompts cmd.Stdin = nil @@ -109,7 +109,7 @@ func (h *AuthHandler) StartGitHubAuth(c *fiber.Ctx) error { err := h.activeAuth.Cmd.Wait() h.authMutex.Lock() defer h.authMutex.Unlock() - + if err != nil && h.activeAuth.Status != "success" { log.Printf("❌ Auth process error: %v", err) h.activeAuth.Status = "error" @@ -137,14 +137,14 @@ func (h *AuthHandler) StartGitHubAuth(c *fiber.Ctx) error { _ = h.activeAuth.Cmd.Process.Kill() } return c.Status(408).JSON(fiber.Map{"error": "Authentication timeout - please try again"}) - + case <-ticker.C: h.authMutex.Lock() code = h.activeAuth.Code url = h.activeAuth.URL status := h.activeAuth.Status h.authMutex.Unlock() - + if code != "" && url != "" { return c.JSON(AuthStartResponse{ Code: code, @@ -203,4 +203,3 @@ func (h *AuthHandler) parseAuthOutput(stdout io.Reader) { } } } - diff --git a/container/internal/handlers/ports.go b/container/internal/handlers/ports.go index f803eab6..3db5f2f5 100644 --- a/container/internal/handlers/ports.go +++ b/container/internal/handlers/ports.go @@ -27,12 +27,12 @@ func NewPortsHandler(monitor *services.PortMonitor) *PortsHandler { // @Router /v1/ports [get] func (h *PortsHandler) GetPorts(c *fiber.Ctx) error { services := h.monitor.GetServices() - + // Convert to a more user-friendly format result := make(map[string]interface{}) result["ports"] = services result["count"] = len(services) - + return c.JSON(result) } @@ -53,13 +53,13 @@ func (h *PortsHandler) GetPortInfo(c *fiber.Ctx) error { "error": "Invalid port number", }) } - + services := h.monitor.GetServices() if service, exists := services[port]; exists { return c.JSON(service) } - + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ "error": "Port not found", }) -} \ No newline at end of file +} diff --git a/container/internal/handlers/sessions.go b/container/internal/handlers/sessions.go index 2fc00410..aeddb65a 100644 --- a/container/internal/handlers/sessions.go +++ b/container/internal/handlers/sessions.go @@ -21,22 +21,22 @@ type SessionsResponse map[string]ActiveSessionInfo // @Description Active session information with timing and Claude session details type ActiveSessionInfo struct { // Unique identifier for the Claude session - ClaudeSessionUUID string `json:"claude_session_uuid" example:"abc123-def456-ghi789"` + ClaudeSessionUUID string `json:"claude_session_uuid" example:"abc123-def456-ghi789"` // Title of the session - Title string `json:"title" example:"Updating README.md"` + Title string `json:"title" example:"Updating README.md"` // When the session was initially started - StartedAt time.Time `json:"started_at" example:"2024-01-15T14:30:00Z"` + StartedAt time.Time `json:"started_at" example:"2024-01-15T14:30:00Z"` // When the session was resumed (if applicable) - ResumedAt *time.Time `json:"resumed_at,omitempty" example:"2024-01-15T16:00:00Z"` + ResumedAt *time.Time `json:"resumed_at,omitempty" example:"2024-01-15T16:00:00Z"` // When the session ended (if not active) - EndedAt *time.Time `json:"ended_at,omitempty" example:"2024-01-15T18:30:00Z"` + EndedAt *time.Time `json:"ended_at,omitempty" example:"2024-01-15T18:30:00Z"` } // DeleteSessionResponse represents the response when deleting a session // @Description Response confirming session deletion type DeleteSessionResponse struct { // Confirmation message - Message string `json:"message" example:"Session deleted successfully"` + Message string `json:"message" example:"Session deleted successfully"` // Workspace path that was deleted Workspace string `json:"workspace" example:"/workspace/my-project"` } @@ -87,23 +87,23 @@ func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { workspace := c.Params("workspace") fullParam := c.Query("full", "false") includeFull := fullParam == "true" - + if includeFull { // Return full session data using Claude service fullData, err := h.claudeService.GetFullSessionData(workspace, true) if err != nil { return c.Status(500).JSON(fiber.Map{ - "error": "Failed to get full session data", + "error": "Failed to get full session data", "details": err.Error(), }) } - + if fullData == nil { return c.Status(404).JSON(fiber.Map{ "error": "Session not found for workspace", }) } - + return c.JSON(fullData) } else { // Try session service first (for active PTY sessions) @@ -111,22 +111,22 @@ func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { if exists { return c.JSON(session) } - + // Fallback to Claude service for basic info (without full data) fullData, err := h.claudeService.GetFullSessionData(workspace, false) if err != nil { return c.Status(500).JSON(fiber.Map{ - "error": "Failed to get session data", + "error": "Failed to get session data", "details": err.Error(), }) } - + if fullData == nil { return c.Status(404).JSON(fiber.Map{ "error": "Session not found for workspace", }) } - + // Return just the session info part for basic requests return c.JSON(fullData.SessionInfo) } @@ -142,15 +142,15 @@ func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { // @Router /v1/sessions/workspace/{workspace} [delete] func (h *SessionsHandler) DeleteSession(c *fiber.Ctx) error { workspace := c.Params("workspace") - + if err := h.sessionService.RemoveActiveSession(workspace); err != nil { return c.Status(500).JSON(fiber.Map{ "error": err.Error(), }) } - + return c.JSON(fiber.Map{ - "message": "Session deleted successfully", + "message": "Session deleted successfully", "workspace": workspace, }) } @@ -167,20 +167,20 @@ func (h *SessionsHandler) DeleteSession(c *fiber.Ctx) error { func (h *SessionsHandler) GetSessionById(c *fiber.Ctx) error { workspace := c.Params("workspace") sessionId := c.Params("sessionId") - + sessionData, err := h.claudeService.GetSessionByID(workspace, sessionId) if err != nil { if err.Error() == "session not found: "+sessionId { return c.Status(404).JSON(fiber.Map{ - "error": "Session not found", + "error": "Session not found", "sessionId": sessionId, }) } return c.Status(500).JSON(fiber.Map{ - "error": "Failed to get session data", + "error": "Failed to get session data", "details": err.Error(), }) } - + return c.JSON(sessionData) -} \ No newline at end of file +} diff --git a/container/internal/handlers/upload.go b/container/internal/handlers/upload.go index 8cef1433..8b2add67 100644 --- a/container/internal/handlers/upload.go +++ b/container/internal/handlers/upload.go @@ -23,11 +23,11 @@ func NewUploadHandler() *UploadHandler { // @Description Response containing upload status and file location type UploadResponse struct { // Whether the upload succeeded - Success bool `json:"success" example:"true"` + Success bool `json:"success" example:"true"` // Path where the uploaded file was saved FilePath string `json:"filePath" example:"/tmp/uploads/document.pdf"` // Status message or error details - Message string `json:"message,omitempty" example:"File uploaded successfully"` + Message string `json:"message,omitempty" example:"File uploaded successfully"` } // UploadFile handles file uploads to /tmp/uploads with conflict resolution @@ -109,7 +109,7 @@ func (h *UploadHandler) resolveConflict(dir, filename string) string { // Split filename into name and extension ext := filepath.Ext(filename) name := strings.TrimSuffix(filename, ext) - + // Try original filename first fullPath := filepath.Join(dir, filename) if _, err := os.Stat(fullPath); os.IsNotExist(err) { @@ -121,13 +121,13 @@ func (h *UploadHandler) resolveConflict(dir, filename string) string { for { numberedName := fmt.Sprintf("%s_%d%s", name, counter, ext) fullPath = filepath.Join(dir, numberedName) - + if _, err := os.Stat(fullPath); os.IsNotExist(err) { return fullPath } - + counter++ - + // Safety check to avoid infinite loops if counter > 9999 { // Use timestamp as fallback @@ -135,4 +135,4 @@ func (h *UploadHandler) resolveConflict(dir, filename string) string { return filepath.Join(dir, numberedName) } } -} \ No newline at end of file +} diff --git a/container/internal/models/settings.go b/container/internal/models/settings.go index 5cdd1641..71c948f2 100644 --- a/container/internal/models/settings.go +++ b/container/internal/models/settings.go @@ -19,7 +19,7 @@ type Settings struct { homePath string lastModTimes map[string]time.Time debounceMap map[string]*time.Timer // For debouncing file changes - syncMutex sync.Mutex // Protects lastModTimes and debounceMap + syncMutex sync.Mutex // Protects lastModTimes and debounceMap } // NewSettings creates a new settings manager @@ -36,14 +36,14 @@ func NewSettings() *Settings { // Start begins the settings synchronization process func (s *Settings) Start() { log.Println("🔧 Starting settings persistence manager") - + // ONLY restore settings from volume on boot - never during runtime log.Println("📥 Boot-time restore: copying settings from volume to home directory") s.restoreFromVolumeOnBoot() - + // Restore IDE directory if it exists s.restoreIDEDirectory() - + // Start the ticker to watch for changes s.ticker = time.NewTicker(5 * time.Second) go s.watchForChanges() @@ -54,7 +54,7 @@ func (s *Settings) Stop() { if s.ticker != nil { s.ticker.Stop() } - + // Cancel all pending debounce timers s.syncMutex.Lock() for _, timer := range s.debounceMap { @@ -62,7 +62,7 @@ func (s *Settings) Stop() { } s.debounceMap = make(map[string]*time.Timer) s.syncMutex.Unlock() - + s.done <- true } @@ -71,19 +71,19 @@ func (s *Settings) restoreFromVolumeOnBoot() { // Create volume directories if they don't exist volumeClaudeDir := filepath.Join(s.volumePath, ".claude") volumeGitHubDir := filepath.Join(s.volumePath, ".github") - + for _, dir := range []string{volumeClaudeDir, volumeGitHubDir} { if err := os.MkdirAll(dir, 0755); err != nil { log.Printf("❌ Failed to create volume directory %s: %v", dir, err) continue } - + // Fix permissions (make it writable by catnip user) if err := os.Chown(dir, 1000, 1000); err != nil { log.Printf("⚠️ Failed to chown volume directory %s: %v", dir, err) } } - + // Create nested directory for credentials volumeClaudeNestedDir := filepath.Join(volumeClaudeDir, ".claude") if err := os.MkdirAll(volumeClaudeNestedDir, 0755); err != nil { @@ -93,7 +93,7 @@ func (s *Settings) restoreFromVolumeOnBoot() { log.Printf("⚠️ Failed to chown nested volume directory %s: %v", volumeClaudeNestedDir, err) } } - + // Files to restore - only restore if home file doesn't exist files := []struct { volumePath string @@ -105,34 +105,34 @@ func (s *Settings) restoreFromVolumeOnBoot() { {volumeGitHubDir, "config.yml", filepath.Join(s.homePath, ".config", "gh", "config.yml")}, {volumeGitHubDir, "hosts.yml", filepath.Join(s.homePath, ".config", "gh", "hosts.yml")}, } - + for _, file := range files { sourcePath := filepath.Join(file.volumePath, file.filename) - + // Check if file exists in volume if _, err := os.Stat(sourcePath); os.IsNotExist(err) { continue } - + // Check if destination file already exists - if so, skip (boot-time only restore) if _, err := os.Stat(file.destPath); err == nil { log.Printf("⚪ Skipping restore of %s - file already exists in home directory", file.filename) continue } - + // Create destination directory if needed destDir := filepath.Dir(file.destPath) if err := os.MkdirAll(destDir, 0755); err != nil { log.Printf("❌ Failed to create directory %s: %v", destDir, err) continue } - + // Copy file from volume to home if err := s.copyFile(sourcePath, file.destPath); err != nil { log.Printf("❌ Failed to restore %s: %v", file.filename, err) } else { log.Printf("✅ Restored %s from volume", file.filename) - + // Set proper ownership for catnip user if err := os.Chown(file.destPath, 1000, 1000); err != nil { log.Printf("⚠️ Failed to chown %s: %v", file.destPath, err) @@ -145,19 +145,19 @@ func (s *Settings) restoreFromVolumeOnBoot() { func (s *Settings) restoreIDEDirectory() { volumeIDEDir := filepath.Join(s.volumePath, ".claude", "ide") homeIDEDir := filepath.Join(s.homePath, ".claude", "ide") - + // Check if volume IDE directory exists if _, err := os.Stat(volumeIDEDir); os.IsNotExist(err) { return } - + log.Printf("📁 Restoring IDE directory from volume") - + // Remove existing home IDE directory if it exists if err := os.RemoveAll(homeIDEDir); err != nil { log.Printf("⚠️ Failed to remove existing IDE directory: %v", err) } - + // Copy the entire directory if err := s.copyDirectory(volumeIDEDir, homeIDEDir); err != nil { log.Printf("❌ Failed to restore IDE directory: %v", err) @@ -173,28 +173,28 @@ func (s *Settings) copyDirectory(src, dst string) error { if err != nil { return err } - + // Create destination directory if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil { return err } - + // Set ownership if err := os.Chown(dst, 1000, 1000); err != nil { log.Printf("⚠️ Failed to chown directory %s: %v", dst, err) } - + // Read source directory entries, err := os.ReadDir(src) if err != nil { return err } - + // Copy each entry for _, entry := range entries { srcPath := filepath.Join(src, entry.Name()) dstPath := filepath.Join(dst, entry.Name()) - + if entry.IsDir() { // Recursively copy subdirectory if err := s.copyDirectory(srcPath, dstPath); err != nil { @@ -218,7 +218,7 @@ func (s *Settings) copyDirectory(src, dst string) error { } } } - + return nil } @@ -229,7 +229,7 @@ func (s *Settings) copyLockFile(src, dst string) error { if err != nil { return err } - + // Parse JSON var lockData map[string]interface{} if err := json.Unmarshal(data, &lockData); err != nil { @@ -237,13 +237,13 @@ func (s *Settings) copyLockFile(src, dst string) error { log.Printf("⚠️ Lock file %s is not valid JSON, copying as-is: %v", src, err) return s.copyFile(src, dst) } - + // Remove the PID key if it exists if _, exists := lockData["pid"]; exists { delete(lockData, "pid") log.Printf("🔧 Removed PID from lock file %s", filepath.Base(src)) } - + // Map workspace folders to container paths if workspaceFolders, exists := lockData["workspaceFolders"]; exists { if folders, ok := workspaceFolders.([]interface{}); ok { @@ -257,18 +257,18 @@ func (s *Settings) copyLockFile(src, dst string) error { } } } - + // Marshal back to JSON with indentation modifiedData, err := json.MarshalIndent(lockData, "", " ") if err != nil { return err } - + // Write to destination if err := os.WriteFile(dst, modifiedData, 0644); err != nil { return err } - + return nil } @@ -277,12 +277,12 @@ func (s *Settings) mapWorkspacePath(hostPath string) string { // Get the final component of the path baseName := filepath.Base(hostPath) containerWorkspacePath := filepath.Join("/workspace", baseName) - + // Check if this workspace exists in the container if _, err := os.Stat(containerWorkspacePath); err == nil { return containerWorkspacePath } - + // If no matching workspace found, return the original path return hostPath } @@ -312,10 +312,10 @@ func (s *Settings) checkAndSyncFiles() { {filepath.Join(s.homePath, ".config", "gh", "config.yml"), filepath.Join(s.volumePath, ".github"), "config.yml", false}, {filepath.Join(s.homePath, ".config", "gh", "hosts.yml"), filepath.Join(s.volumePath, ".github"), "hosts.yml", false}, } - + s.syncMutex.Lock() defer s.syncMutex.Unlock() - + for _, file := range files { // Check if source file exists info, err := os.Stat(file.sourcePath) @@ -326,13 +326,13 @@ func (s *Settings) checkAndSyncFiles() { log.Printf("❌ Error checking file %s: %v", file.sourcePath, err) continue } - + // Check if file has been modified lastMod, exists := s.lastModTimes[file.sourcePath] if exists && info.ModTime().Equal(lastMod) { continue } - + // File has changed - schedule debounced sync s.scheduleDebounceSync(file.sourcePath, file.volumeDir, file.destName, file.sensitive, info.ModTime()) } @@ -344,13 +344,13 @@ func (s *Settings) scheduleDebounceSync(sourcePath, volumeDir, destName string, if timer, exists := s.debounceMap[sourcePath]; exists { timer.Stop() } - + // Create new debounced timer debounceDelay := 2 * time.Second if sensitive { debounceDelay = 5 * time.Second // Extra delay for sensitive files } - + s.debounceMap[sourcePath] = time.AfterFunc(debounceDelay, func() { s.performSafeSync(sourcePath, volumeDir, destName, sensitive, modTime) }) @@ -360,7 +360,7 @@ func (s *Settings) scheduleDebounceSync(sourcePath, volumeDir, destName string, func (s *Settings) performSafeSync(sourcePath, volumeDir, destName string, sensitive bool, expectedModTime time.Time) { s.syncMutex.Lock() defer s.syncMutex.Unlock() - + // Double-check the file still exists and hasn't changed again info, err := os.Stat(sourcePath) if os.IsNotExist(err) { @@ -371,13 +371,13 @@ func (s *Settings) performSafeSync(sourcePath, volumeDir, destName string, sensi log.Printf("❌ Error re-checking file %s: %v", sourcePath, err) return } - + // If file has been modified again since we scheduled this sync, skip it if !info.ModTime().Equal(expectedModTime) { log.Printf("⚠️ File %s was modified again, skipping this sync", sourcePath) return } - + // For sensitive files, check for potential lock files or concurrent access if sensitive { if s.isFileBeingAccessed(sourcePath) { @@ -389,30 +389,30 @@ func (s *Settings) performSafeSync(sourcePath, volumeDir, destName string, sensi return } } - + destPath := filepath.Join(volumeDir, destName) - + // Ensure volume directory exists if err := os.MkdirAll(volumeDir, 0755); err != nil { log.Printf("❌ Failed to create volume directory: %v", err) return } - + // Perform the sync with atomic write for sensitive files if sensitive { err = s.copyFileAtomic(sourcePath, destPath) } else { err = s.copyFile(sourcePath, destPath) } - + if err != nil { log.Printf("❌ Failed to sync %s to volume: %v", sourcePath, err) return } - + // Try to fix ownership (might fail if not root) _ = os.Chown(destPath, 1000, 1000) - + s.lastModTimes[sourcePath] = info.ModTime() log.Printf("📋 Synced %s to volume", destName) } @@ -426,14 +426,14 @@ func (s *Settings) isFileBeingAccessed(filePath string) bool { filepath.Dir(filePath) + "/.lock", filepath.Dir(filePath) + "/lock", } - + for _, lockPath := range lockPatterns { if _, err := os.Stat(lockPath); err == nil { log.Printf("🔒 Lock file detected: %s", lockPath) return true } } - + // Try to open the file exclusively to see if it's in use file, err := os.OpenFile(filePath, os.O_RDONLY, 0) if err != nil { @@ -441,7 +441,7 @@ func (s *Settings) isFileBeingAccessed(filePath string) bool { return true } file.Close() - + return false } @@ -449,18 +449,18 @@ func (s *Settings) isFileBeingAccessed(filePath string) bool { func (s *Settings) copyFileAtomic(src, dst string) error { // Create temp file in same directory as destination tempFile := dst + ".tmp." + filepath.Base(src) - + // Copy to temp file first if err := s.copyFile(src, tempFile); err != nil { return err } - + // Atomically rename temp file to final destination if err := os.Rename(tempFile, dst); err != nil { os.Remove(tempFile) // Clean up temp file on error return err } - + return nil } @@ -472,31 +472,31 @@ func (s *Settings) copyFile(src, dst string) error { return err } defer sourceFile.Close() - + // Get source file stats to preserve permissions sourceInfo, err := sourceFile.Stat() if err != nil { return err } - + // Create destination file destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() - + // Copy contents if _, err := io.Copy(destFile, sourceFile); err != nil { return err } - + // Try to preserve permissions if err := destFile.Chmod(sourceInfo.Mode()); err != nil { // Log but don't fail log.Printf("⚠️ Could not preserve permissions on %s: %v", dst, err) } - + return nil } @@ -506,23 +506,23 @@ func (s *Settings) ValidateSettings() error { filepath.Join(s.homePath, ".claude", ".credentials.json"), filepath.Join(s.homePath, ".claude.json"), } - + for _, file := range files { if _, err := os.Stat(file); os.IsNotExist(err) { continue } - + data, err := os.ReadFile(file) if err != nil { return err } - + var js json.RawMessage if err := json.Unmarshal(data, &js); err != nil { log.Printf("⚠️ Invalid JSON in %s: %v", file, err) return err } } - + return nil -} \ No newline at end of file +} diff --git a/container/internal/services/commit_sync.go b/container/internal/services/commit_sync.go index a8234ca0..e0dae1fb 100644 --- a/container/internal/services/commit_sync.go +++ b/container/internal/services/commit_sync.go @@ -115,7 +115,7 @@ func (css *CommitSyncService) Stop() { // setupWatchers sets up filesystem watchers for existing worktrees func (css *CommitSyncService) setupWatchers() { worktrees := css.gitService.ListWorktrees() - + for _, worktree := range worktrees { css.addWorktreeWatcher(worktree.Path) } @@ -219,14 +219,14 @@ func (css *CommitSyncService) handleCommitEvent(event fsnotify.Event) { func (css *CommitSyncService) extractWorktreePath(refsPath string) string { // Convert /workspace/repo/branch/.git/refs/heads/branchname to /workspace/repo/branch parts := strings.Split(refsPath, string(filepath.Separator)) - + for i, part := range parts { if part == ".git" && i > 0 { // Return path up to but not including .git return filepath.Join(parts[:i]...) } } - + return "" } @@ -299,7 +299,7 @@ func (css *CommitSyncService) syncCommitToBareRepo(commitInfo *CommitInfo) error checkCmd := exec.Command("git", "-C", bareRepoPath, "cat-file", "-e", commitInfo.CommitHash) if err := checkCmd.Run(); err == nil { // Commit already exists, just update the ref - updateRefCmd := exec.Command("git", "-C", bareRepoPath, "update-ref", + updateRefCmd := exec.Command("git", "-C", bareRepoPath, "update-ref", fmt.Sprintf("refs/heads/%s", commitInfo.Branch), commitInfo.CommitHash) updateOutput, err := updateRefCmd.CombinedOutput() if err != nil { @@ -313,11 +313,11 @@ func (css *CommitSyncService) syncCommitToBareRepo(commitInfo *CommitInfo) error // Create unique remote name using repo ID to avoid conflicts between repositories repoID := strings.ReplaceAll(repo.ID, "/", "-") remoteName := fmt.Sprintf("sync-%s-%s", repoID, strings.ReplaceAll(commitInfo.Branch, "/", "-")) - + // Remove existing remote first to avoid conflicts removeRemoteCmd := exec.Command("git", "-C", bareRepoPath, "remote", "remove", remoteName) _ = removeRemoteCmd.Run() // Ignore error - remote might not exist - + // Add remote addRemoteCmd := exec.Command("git", "-C", bareRepoPath, "remote", "add", remoteName, commitInfo.WorktreePath) if err := addRemoteCmd.Run(); err != nil { @@ -338,7 +338,7 @@ func (css *CommitSyncService) syncCommitToBareRepo(commitInfo *CommitInfo) error } else { fetchCmd = exec.Command("git", "-C", bareRepoPath, "fetch", remoteName, commitInfo.Branch) } - + output, err := fetchCmd.CombinedOutput() if err != nil { // If unshallow fails, try regular fetch as fallback @@ -355,7 +355,7 @@ func (css *CommitSyncService) syncCommitToBareRepo(commitInfo *CommitInfo) error } // Update the branch ref in the bare repository - updateRefCmd := exec.Command("git", "-C", bareRepoPath, "update-ref", + updateRefCmd := exec.Command("git", "-C", bareRepoPath, "update-ref", fmt.Sprintf("refs/heads/%s", commitInfo.Branch), commitInfo.CommitHash) updateOutput, err := updateRefCmd.CombinedOutput() if err != nil { @@ -393,7 +393,7 @@ func (css *CommitSyncService) PerformManualSync() { // performPeriodicSync checks all worktrees for unsync'd commits func (css *CommitSyncService) performPeriodicSync() { worktrees := css.gitService.ListWorktrees() - + for _, worktree := range worktrees { // Check if worktree has commits that aren't in the bare repo if css.hasUnsyncedCommits(worktree.Path) { @@ -427,7 +427,7 @@ func (css *CommitSyncService) cleanupOrphanedRemotes() { // cleanupOrphanedRemotesForRepo removes orphaned remotes for a specific repository func (css *CommitSyncService) cleanupOrphanedRemotesForRepo(bareRepoPath string) { - + // List all remotes cmd := exec.Command("git", "-C", bareRepoPath, "remote") output, err := cmd.Output() @@ -481,4 +481,4 @@ func (css *CommitSyncService) hasUnsyncedCommits(worktreePath string) bool { // Compare HEADs return strings.TrimSpace(string(worktreeHead)) != strings.TrimSpace(string(bareHead)) -} \ No newline at end of file +} diff --git a/container/internal/services/git_http.go b/container/internal/services/git_http.go index dda4cb40..44ccec17 100644 --- a/container/internal/services/git_http.go +++ b/container/internal/services/git_http.go @@ -36,44 +36,44 @@ func (ghs *GitHTTPService) RegisterRoutes(app *fiber.App) { // handleGitHTTP handles Git HTTP protocol requests using git http-backend func (ghs *GitHTTPService) handleGitHTTP(c *fiber.Ctx) error { path := c.Path() - + // Git HTTP request received - + // Extract repo name from path (e.g., "/repo.git" or "/repo.git/info/refs") pathParts := strings.Split(strings.TrimPrefix(path, "/"), "/") if len(pathParts) == 0 { return c.Status(404).SendString("Repository not found") } - + repoWithGit := pathParts[0] // e.g., "repo.git" if !strings.HasSuffix(repoWithGit, ".git") { return c.Status(404).SendString("Invalid repository URL") } - + repoName := strings.TrimSuffix(repoWithGit, ".git") - + // Find the repository by name from all loaded repositories status := ghs.gitService.GetStatus() var targetRepo *models.Repository - + // Search through all repositories to find one matching the requested repo name for _, repo := range status.Repositories { // Extract repo name from repository ID (e.g., "owner/repo" -> "repo") repoParts := strings.Split(repo.ID, "/") actualRepoName := repoParts[len(repoParts)-1] - + if repoName == actualRepoName { targetRepo = repo break } } - + if targetRepo == nil { return c.Status(404).SendString("Repository not found") } - + bareRepoPath := targetRepo.Path - + // Create a unique temporary symlink structure for git http-backend // git http-backend expects the repository at $GIT_PROJECT_ROOT/$PATH_INFO // Use repo name to avoid conflicts between different repositories @@ -82,21 +82,21 @@ func (ghs *GitHTTPService) handleGitHTTP(c *fiber.Ctx) error { log.Printf("❌ Failed to create temp directory: %v", err) return c.Status(500).SendString("Internal server error") } - + symlinkPath := filepath.Join(tempDir, repoWithGit) - + // Remove existing symlink if it exists os.Remove(symlinkPath) - + // Create symlink to bare repository if err := os.Symlink(bareRepoPath, symlinkPath); err != nil { log.Printf("❌ Failed to create symlink: %v", err) return c.Status(500).SendString("Internal server error") } - + // Clean up symlink after request defer os.Remove(symlinkPath) - + // Set up CGI environment for git http-backend env := append(os.Environ(), fmt.Sprintf("GIT_PROJECT_ROOT=%s", tempDir), @@ -116,43 +116,43 @@ func (ghs *GitHTTPService) handleGitHTTP(c *fiber.Ctx) error { "HOME=/home/catnip", "USER=catnip", ) - + // Add HTTP headers as environment variables c.Request().Header.VisitAll(func(key, value []byte) { headerKey := fmt.Sprintf("HTTP_%s", strings.ToUpper(strings.ReplaceAll(string(key), "-", "_"))) env = append(env, fmt.Sprintf("%s=%s", headerKey, string(value))) }) - + // Check what refs exist in the bare repository (silent) // Execute git http-backend cmd := exec.Command("git", "http-backend") cmd.Env = env cmd.Dir = tempDir - + // Set up I/O cmd.Stdin = bytes.NewReader(c.Body()) - + var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr - + if err := cmd.Run(); err != nil { log.Printf("❌ Git http-backend error: %v, stderr: %s", err, stderr.String()) return c.Status(500).SendString("Git operation failed") } - + // Parse CGI response (work with bytes to preserve binary data) responseBytes := stdout.Bytes() // Git response processed - + // Find the end of headers (double CRLF) headerEndIndex := bytes.Index(responseBytes, []byte("\r\n\r\n")) if headerEndIndex != -1 { // Split headers and body headerBytes := responseBytes[:headerEndIndex] bodyBytes := responseBytes[headerEndIndex+4:] // Skip the \r\n\r\n - + // Parse and set headers headerLines := strings.Split(string(headerBytes), "\r\n") for _, line := range headerLines { @@ -164,7 +164,7 @@ func (ghs *GitHTTPService) handleGitHTTP(c *fiber.Ctx) error { c.Set(headerParts[0], headerParts[1]) } } - + // Send binary body (preserves Git protocol data) return c.Send(bodyBytes) } else { @@ -183,12 +183,12 @@ func (ghs *GitHTTPService) handleGitHTTP(c *fiber.Ctx) error { hasValidHeaders = true } } - + if hasValidHeaders { return c.SendStatus(200) // Headers only response } } - + // Fallback: send as binary c.Set("Content-Type", "application/octet-stream") return c.Send(responseBytes) @@ -201,11 +201,11 @@ func (ghs *GitHTTPService) GetRepositoryCloneURL(baseURL, repoID string) string if repo == nil { return "" } - + // Extract repo name from repository ID repoParts := strings.Split(repo.ID, "/") repoName := repoParts[len(repoParts)-1] - + return fmt.Sprintf("%s/%s.git", baseURL, repoName) } @@ -213,12 +213,12 @@ func (ghs *GitHTTPService) GetRepositoryCloneURL(baseURL, repoID string) string func (ghs *GitHTTPService) GetAllRepositoryCloneURLs(baseURL string) map[string]string { status := ghs.gitService.GetStatus() urls := make(map[string]string) - + for repoID, repo := range status.Repositories { repoParts := strings.Split(repo.ID, "/") repoName := repoParts[len(repoParts)-1] urls[repoID] = fmt.Sprintf("%s/%s.git", baseURL, repoName) } - + return urls -} \ No newline at end of file +} diff --git a/container/internal/tui/pty_client.go b/container/internal/tui/pty_client.go index 4a35dd83..eec04ce3 100644 --- a/container/internal/tui/pty_client.go +++ b/container/internal/tui/pty_client.go @@ -10,12 +10,12 @@ import ( ) type PTYClient struct { - conn *websocket.Conn - sessionID string - mu sync.Mutex - onMessage func([]byte) - onError func(error) - done chan struct{} + conn *websocket.Conn + sessionID string + mu sync.Mutex + onMessage func([]byte) + onError func(error) + done chan struct{} } type ResizeMessage struct { @@ -66,7 +66,7 @@ func (p *PTYClient) Connect(baseURL string) error { func (p *PTYClient) readLoop() { defer close(p.done) - + for { messageType, message, err := p.conn.ReadMessage() if err != nil { @@ -133,4 +133,4 @@ func (p *PTYClient) SetErrorHandler(handler func(error)) { func (p *PTYClient) Wait() { <-p.done -} \ No newline at end of file +} diff --git a/container/internal/tui/shell_manager.go b/container/internal/tui/shell_manager.go index b93445cc..a0543326 100644 --- a/container/internal/tui/shell_manager.go +++ b/container/internal/tui/shell_manager.go @@ -116,4 +116,3 @@ func createAndConnectShell(sessionID string, width, height int) tea.Cmd { return nil } } - diff --git a/container/internal/tui/terminal_emulator.go b/container/internal/tui/terminal_emulator.go index a3d46d6d..555587ec 100644 --- a/container/internal/tui/terminal_emulator.go +++ b/container/internal/tui/terminal_emulator.go @@ -42,7 +42,6 @@ func (te *TerminalEmulator) Write(data []byte) { _, _ = te.terminal.Write(data) } - // Resize updates the terminal dimensions func (te *TerminalEmulator) Resize(cols, rows int) { te.cols = cols @@ -50,35 +49,34 @@ func (te *TerminalEmulator) Resize(cols, rows int) { te.terminal.Resize(cols, rows) } - // Render returns the current terminal view as a string with ANSI color codes func (te *TerminalEmulator) Render() string { var buf bytes.Buffer - + // Get cursor information cursor := te.terminal.Cursor() cursorVisible := te.terminal.CursorVisible() - + // Track current attributes to minimize ANSI codes var lastFg, lastBg vt10x.Color var lastMode int16 resetNeeded := false - + for row := 0; row < te.rows; row++ { if row > 0 { buf.WriteString("\n") } - + for col := 0; col < te.cols; col++ { cell := te.terminal.Cell(col, row) - + // Handle colors and attributes if cell.FG != lastFg || cell.BG != lastBg || cell.Mode != lastMode { // Reset if needed if resetNeeded { buf.WriteString("\033[0m") } - + // Apply new attributes based on Mode if cell.Mode&attrBold != 0 { buf.WriteString("\033[1m") @@ -89,7 +87,7 @@ func (te *TerminalEmulator) Render() string { if cell.Mode&attrReverse != 0 { buf.WriteString("\033[7m") } - + // Apply foreground color if cell.FG != vt10x.DefaultFG { if cell.FG < 8 { @@ -109,7 +107,7 @@ func (te *TerminalEmulator) Render() string { buf.WriteString(fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)) } } - + // Apply background color if cell.BG != vt10x.DefaultBG { if cell.BG < 8 { @@ -129,13 +127,13 @@ func (te *TerminalEmulator) Render() string { buf.WriteString(fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)) } } - + lastFg = cell.FG lastBg = cell.BG lastMode = cell.Mode resetNeeded = true } - + // Handle cursor position if cursorVisible && row == cursor.Y && col == cursor.X { // Use reverse video for cursor @@ -159,16 +157,16 @@ func (te *TerminalEmulator) Render() string { } } } - + // Final reset if needed if resetNeeded { buf.WriteString("\033[0m") } - + // Trim trailing empty lines output := buf.String() lines := strings.Split(output, "\n") - + // Find last non-empty line lastNonEmpty := -1 for i := len(lines) - 1; i >= 0; i-- { @@ -177,12 +175,12 @@ func (te *TerminalEmulator) Render() string { break } } - + if lastNonEmpty >= 0 { lines = lines[:lastNonEmpty+1] output = strings.Join(lines, "\n") } - + return output } @@ -195,4 +193,4 @@ func (te *TerminalEmulator) GetCursorPosition() (row, col int) { // Clear clears the terminal func (te *TerminalEmulator) Clear() { _, _ = te.terminal.Write([]byte("\033[2J\033[H")) -} \ No newline at end of file +} diff --git a/src/components/WorktreeRow.tsx b/src/components/WorktreeRow.tsx index 16fe5645..9094d60a 100644 --- a/src/components/WorktreeRow.tsx +++ b/src/components/WorktreeRow.tsx @@ -561,6 +561,7 @@ interface WorktreeActionsProps { commitCount: number, ) => void; onOpenPrDialog: (worktreeId: string, branchName: string) => void; + onBranchFromWorktree: (worktreeId: string, name: string) => void; } function WorktreeActions({ @@ -575,6 +576,7 @@ function WorktreeActions({ onCreatePreview, onConfirmDelete, onOpenPrDialog, + onBranchFromWorktree, }: WorktreeActionsProps) { const hasDiff = (diffStats[worktree.id]?.file_diffs?.length ?? 0) > 0; @@ -611,7 +613,7 @@ function WorktreeActions({ onCreatePreview={onCreatePreview} onConfirmDelete={onConfirmDelete} onOpenPrDialog={onOpenPrDialog} - onBranchFromWorktree={handleBranchFromWorktree} + onBranchFromWorktree={onBranchFromWorktree} />
); @@ -747,6 +749,7 @@ export function WorktreeRow({ onCreatePreview={onCreatePreview} onConfirmDelete={onConfirmDelete} onOpenPrDialog={openPrDialog} + onBranchFromWorktree={handleBranchFromWorktree} />
Date: Thu, 17 Jul 2025 12:09:20 -0700 Subject: [PATCH 8/8] bugfixes (#36) --- container/internal/handlers/pty.go | 3 +- container/internal/services/git.go | 51 ++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/container/internal/handlers/pty.go b/container/internal/handlers/pty.go index 6ffa8c1f..d5714b29 100644 --- a/container/internal/handlers/pty.go +++ b/container/internal/handlers/pty.go @@ -86,7 +86,8 @@ func sanitizeTitle(title string) string { safeTitle = safeTitle[:100] } - return safeTitle + // Strip leading/trailing whitespace + return strings.TrimSpace(safeTitle) } // extractTitleFromEscapeSequence extracts the fancy Claude terminal title from escape sequences diff --git a/container/internal/services/git.go b/container/internal/services/git.go index a0034247..0dce42bf 100644 --- a/container/internal/services/git.go +++ b/container/internal/services/git.go @@ -83,6 +83,33 @@ func (s *GitService) runGitCommand(workingDir string, args ...string) ([]byte, e return cmd.CombinedOutput() } +// ensureRepositorySafe ensures that a repository is safe to work with by handling dubious ownership issues +func (s *GitService) ensureRepositorySafe(repoPath string) error { + // Quick check if repository is already safe + output, err := s.runGitCommand(repoPath, "rev-parse", "--git-dir") + if err == nil { + return nil // Repository is already safe + } + + // Check if it's a dubious ownership issue (check both error and output) + errorText := err.Error() + string(output) + if strings.Contains(errorText, "dubious ownership") { + // Add to safe.directory + _, safeErr := s.runGitCommand("", "config", "--global", "--add", "safe.directory", repoPath) + if safeErr != nil { + return fmt.Errorf("failed to add repository to safe.directory: %v", safeErr) + } + + // Verify it's now safe + _, verifyErr := s.runGitCommand(repoPath, "rev-parse", "--git-dir") + if verifyErr != nil { + return fmt.Errorf("repository still not safe after adding to safe.directory: %v", verifyErr) + } + } + + return nil +} + // RemoteURLManager handles remote URL operations with conversion and restoration type RemoteURLManager struct { service *GitService @@ -266,19 +293,24 @@ func (s *GitService) branchExists(repoPath, branch string, isRemote bool) bool { // branchExistsWithOptions checks if a branch exists in a repository with full options func (s *GitService) branchExistsWithOptions(repoPath, branch string, opts BranchExistsOptions) bool { - var ref string if opts.IsRemote { remoteName := opts.RemoteName if remoteName == "" { remoteName = "origin" } - ref = fmt.Sprintf("refs/remotes/%s/%s", remoteName, branch) + ref := fmt.Sprintf("refs/remotes/%s/%s", remoteName, branch) + cmd := s.execGitCommand(repoPath, "show-ref", "--verify", "--quiet", ref) + return cmd.Run() == nil } else { - ref = fmt.Sprintf("refs/heads/%s", branch) - } + // For local branches, use git branch --list which is more reliable + output, err := s.runGitCommand(repoPath, "branch", "--list", branch) + if err != nil { + return false + } - cmd := s.execGitCommand(repoPath, "show-ref", "--verify", "--quiet", ref) - return cmd.Run() == nil + // Check if the output contains the branch name + return strings.Contains(string(output), branch) + } } // getCommitCount counts commits between two refs @@ -898,6 +930,11 @@ func (s *GitService) handleLocalRepoWorktree(repoID, branch string) (*models.Rep return nil, nil, fmt.Errorf("local repository %s not found - it may not be mounted", repoID) } + // Ensure repository is safe to work with + if err := s.ensureRepositorySafe(localRepo.Path); err != nil { + return nil, nil, fmt.Errorf("failed to ensure repository safety: %v", err) + } + // If no branch specified, use current branch if branch == "" { branch = localRepo.DefaultBranch @@ -1866,7 +1903,7 @@ func (s *GitService) GitAddCommitGetHash(workspaceDir, message string) (string, } // Commit with the message - if output, err := s.runGitCommand(workspaceDir, "commit", "-m", message); err != nil { + if output, err := s.runGitCommand(workspaceDir, "commit", "-m", message, "-n"); err != nil { return "", fmt.Errorf("git commit failed: %v, output: %s", err, string(output)) }