diff --git a/docs/vnext/FEATURES.md b/docs/vnext/FEATURES.md index b503016b..12684ec5 100644 --- a/docs/vnext/FEATURES.md +++ b/docs/vnext/FEATURES.md @@ -62,6 +62,8 @@ daemon 解析 to 字段 | Telegram | ✅ 完成 | `token_env` | | Slack | ✅ 完成 | `bot_token_env` + `app_token_env` | | Discord | ✅ 完成 | `token_env` | +| Feishu | ✅ 完成 | `FEISHU_APP_ID` + `FEISHU_APP_SECRET` | +| DingTalk | ✅ 完成 | `DINGTALK_APP_KEY` + `DINGTALK_APP_SECRET` | ### 2.3 配置 @@ -76,6 +78,19 @@ im: platform: slack bot_token_env: SLACK_BOT_TOKEN # xoxb-... Web API app_token_env: SLACK_APP_TOKEN # xapp-... Socket Mode + +# Feishu 使用环境变量(App ID/Secret) +# export FEISHU_APP_ID=xxx +# export FEISHU_APP_SECRET=xxx +im: + platform: feishu + +# DingTalk 使用环境变量(App Key/Secret;可选 Robot Code) +# export DINGTALK_APP_KEY=xxx +# export DINGTALK_APP_SECRET=xxx +# export DINGTALK_ROBOT_CODE=xxx # optional +im: + platform: dingtalk ``` ### 2.4 IM 命令 diff --git a/docs/vnext/STATUS.md b/docs/vnext/STATUS.md index 5959a16a..7b814964 100644 --- a/docs/vnext/STATUS.md +++ b/docs/vnext/STATUS.md @@ -41,6 +41,8 @@ - Telegram adapter 完成 - Slack adapter 完成(Socket Mode + Web API) - Discord adapter 完成(Gateway) +- Feishu adapter 完成(WebSocket + REST) +- DingTalk adapter 完成(Stream mode + REST) - CLI 命令完成 - Web UI 配置完成 diff --git a/src/cccc/contracts/v1/event.py b/src/cccc/contracts/v1/event.py index 4a124da0..f4aa9778 100644 --- a/src/cccc/contracts/v1/event.py +++ b/src/cccc/contracts/v1/event.py @@ -60,6 +60,7 @@ class GroupAttachData(BaseModel): url: str label: str = "" git_remote: str = "" + worktree_path: str = "" model_config = ConfigDict(extra="forbid") diff --git a/src/cccc/daemon/ops/template_ops.py b/src/cccc/daemon/ops/template_ops.py index 4aa9691b..48854790 100644 --- a/src/cccc/daemon/ops/template_ops.py +++ b/src/cccc/daemon/ops/template_ops.py @@ -602,7 +602,7 @@ def group_create_from_template(args: Dict[str, Any]) -> DaemonResponse: group_id=group.group_id, scope_key=scope.scope_key, by=by, - data={"url": scope.url, "label": scope.label, "git_remote": scope.git_remote}, + data={"url": scope.url, "label": scope.label, "git_remote": scope.git_remote, "worktree_path": ""}, ) except Exception: pass diff --git a/src/cccc/daemon/server.py b/src/cccc/daemon/server.py index a7c8334b..3a71514a 100644 --- a/src/cccc/daemon/server.py +++ b/src/cccc/daemon/server.py @@ -1595,20 +1595,46 @@ def handle_request(req: DaemonRequest) -> Tuple[DaemonResponse, bool]: scope = detect_scope(path) reg = load_registry() requested_group_id = str(args.get("group_id") or "").strip() + create_worktree = bool(args.get("create_worktree", False)) + worktree_branch = str(args.get("worktree_branch") or "").strip() + base_branch = str(args.get("base_branch") or "").strip() + if requested_group_id: group = load_group(requested_group_id) if group is None: return _error("group_not_found", f"group not found: {requested_group_id}"), False - group = attach_scope_to_group(reg, group, scope, set_active=True) + try: + group = attach_scope_to_group( + reg, group, scope, set_active=True, + create_worktree=create_worktree, + worktree_branch=worktree_branch, + base_branch=base_branch, + ) + except ValueError as e: + return _error("worktree_failed", str(e)), False else: group = ensure_group_for_scope(reg, scope) + + # Get worktree_path from the attached scope + worktree_path = "" + scopes_list = group.doc.get("scopes", []) + for sc in scopes_list: + if isinstance(sc, dict) and sc.get("scope_key") == scope.scope_key: + worktree_path = str(sc.get("worktree_path") or "") + break + append_event( group.ledger_path, kind="group.attach", group_id=group.group_id, scope_key=scope.scope_key, by=str(args.get("by") or "cli"), - data={"url": scope.url, "label": scope.label, "git_remote": scope.git_remote}, + data={ + "url": scope.url, + "label": scope.label, + "git_remote": scope.git_remote, + "worktree_path": worktree_path, + }, ) return ( DaemonResponse( diff --git a/src/cccc/kernel/git.py b/src/cccc/kernel/git.py index 880a8562..7bc5c6d0 100644 --- a/src/cccc/kernel/git.py +++ b/src/cccc/kernel/git.py @@ -12,11 +12,15 @@ def _run_git(args: list[str], *, cwd: Path) -> tuple[int, str]: ["git", *args], cwd=str(cwd), stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, text=True, check=False, ) - return int(p.returncode), (p.stdout or "").strip() + # Return stdout if successful, stderr if failed + output = (p.stdout or "").strip() + if p.returncode != 0 and not output: + output = (p.stderr or "").strip() + return int(p.returncode), output except Exception: return 1, "" @@ -64,3 +68,245 @@ def normalize_git_remote(url: str) -> str: return u return u + +# ============ Git Branch Functions ============ + + +def git_current_branch(repo_root: Path) -> Optional[str]: + """Get the current branch name. + + Args: + repo_root: Path to the repository + + Returns: + Current branch name, or None if detached HEAD or error + """ + code, out = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_root) + if code != 0 or not out: + return None + branch = out.strip() + if branch == "HEAD": + # Detached HEAD state + return None + return branch + + +def git_branch_exists(repo_root: Path, branch: str) -> bool: + """Check if a branch exists in the repository. + + Args: + repo_root: Path to the repository + branch: Branch name to check + + Returns: + True if the branch exists, False otherwise + """ + code, _ = _run_git(["rev-parse", "--verify", f"refs/heads/{branch}"], cwd=repo_root) + return code == 0 + + +def git_remote_ref_exists(repo_root: Path, ref: str) -> bool: + """Check if a remote ref exists (e.g., origin/main). + + Args: + repo_root: Path to the repository + ref: Remote ref to check (e.g., "origin/main") + + Returns: + True if the remote ref exists, False otherwise + """ + code, _ = _run_git(["rev-parse", "--verify", f"refs/remotes/{ref}"], cwd=repo_root) + return code == 0 + + +def git_list_branches(repo_root: Path, *, include_remote: bool = False) -> list[dict]: + """List all branches with their worktree occupation status. + + Args: + repo_root: Path to the repository + include_remote: If True, include remote tracking branches + + Returns: + List of dicts with keys: + - name: branch name (without refs/heads/ or refs/remotes/ prefix) + - is_remote: True if this is a remote tracking branch + - in_use: True if this branch is checked out in a worktree + - worktree_path: Path to the worktree using this branch, or None + - is_current: True if this is the current branch (HEAD) + """ + # Get current branch + current_branch = git_current_branch(repo_root) + + # Get local branches + code, out = _run_git(["branch", "--format=%(refname:short)"], cwd=repo_root) + local_branches = out.split("\n") if code == 0 and out else [] + local_branches = [b.strip() for b in local_branches if b.strip()] + + # Get remote branches if requested + remote_branches: list[str] = [] + if include_remote: + code, out = _run_git(["branch", "-r", "--format=%(refname:short)"], cwd=repo_root) + if code == 0 and out: + remote_branches = [b.strip() for b in out.split("\n") if b.strip()] + # Filter out HEAD pointers like "origin/HEAD" and bare remote names like "origin" + remote_branches = [b for b in remote_branches if not b.endswith("/HEAD")] + remote_branches = [b for b in remote_branches if "/" in b] + + # Get worktree info to determine which branches are in use + worktrees = git_worktree_list(repo_root) + branch_to_worktree: dict[str, str] = {} + for wt in worktrees: + branch = wt.get("branch", "") + # Branch is stored as refs/heads/xxx, extract the name + if branch.startswith("refs/heads/"): + branch = branch[len("refs/heads/"):] + if branch and branch not in ("(detached)", "(bare)"): + branch_to_worktree[branch] = wt.get("path", "") + + # Build result + result: list[dict] = [] + + # Only include the current local branch (not all local branches) + # User wants: current branch first, then remote branches only + if current_branch and current_branch in local_branches: + result.append({ + "name": current_branch, + "is_remote": False, + "in_use": current_branch in branch_to_worktree, + "worktree_path": branch_to_worktree.get(current_branch), + "is_current": True, + }) + + for branch in remote_branches: + result.append({ + "name": branch, + "is_remote": True, + "in_use": False, # Remote branches can't be directly checked out in worktrees + "worktree_path": None, + "is_current": False, # Remote branches can't be current + }) + + return result + + +# ============ Git Worktree Functions ============ + + +def git_worktree_add( + repo_root: Path, + worktree_path: Path, + branch: str, + *, + create_branch: bool = True, + start_point: str = "", +) -> tuple[bool, str]: + """Create a new git worktree. + + Args: + repo_root: Path to the main repository + worktree_path: Path where the worktree will be created + branch: Branch name for the worktree + create_branch: If True, create a new branch (-b flag); if False, use existing branch + start_point: Starting point for the new branch (e.g., "origin/main", "main"). + Only used when create_branch=True. If empty, defaults to HEAD. + + Returns: + (success, message) tuple + """ + args = ["worktree", "add"] + if create_branch: + args.extend(["-b", branch]) + args.append(str(worktree_path)) + if create_branch and start_point: + # git worktree add -b + args.append(start_point) + elif not create_branch: + args.append(branch) + + code, out = _run_git(args, cwd=repo_root) + if code == 0: + return True, f"Worktree created at {worktree_path}" + return False, out or "Failed to create worktree" + + +def git_worktree_remove(repo_root: Path, worktree_path: Path, *, force: bool = False) -> tuple[bool, str]: + """Remove a git worktree. + + Args: + repo_root: Path to the main repository + worktree_path: Path of the worktree to remove + force: If True, force removal even with uncommitted changes + + Returns: + (success, message) tuple + """ + args = ["worktree", "remove"] + if force: + args.append("--force") + args.append(str(worktree_path)) + + code, out = _run_git(args, cwd=repo_root) + if code == 0: + return True, f"Worktree removed: {worktree_path}" + return False, out or "Failed to remove worktree" + + +def git_worktree_list(repo_root: Path) -> list[dict[str, str]]: + """List all worktrees for a repository. + + Args: + repo_root: Path to the main repository + + Returns: + List of dicts with 'path', 'commit', 'branch' keys + """ + code, out = _run_git(["worktree", "list", "--porcelain"], cwd=repo_root) + if code != 0 or not out: + return [] + + worktrees = [] + current: dict[str, str] = {} + + for line in out.split("\n"): + line = line.strip() + if not line: + if current: + worktrees.append(current) + current = {} + continue + if line.startswith("worktree "): + current["path"] = line[9:] + elif line.startswith("HEAD "): + current["commit"] = line[5:] + elif line.startswith("branch "): + current["branch"] = line[7:] + elif line == "detached": + current["branch"] = "(detached)" + elif line == "bare": + current["branch"] = "(bare)" + + if current: + worktrees.append(current) + + return worktrees + + +def git_is_worktree(path: Path) -> bool: + """Check if a path is inside a git worktree (not the main working tree). + + Args: + path: Path to check + + Returns: + True if path is in a worktree, False otherwise + """ + code, out = _run_git(["rev-parse", "--is-inside-work-tree"], cwd=path) + if code != 0: + return False + + # Check if this is a linked worktree by looking for .git file (not directory) + git_path = path / ".git" + if git_path.is_file(): + return True # Linked worktree has .git as a file pointing to main repo + return False + diff --git a/src/cccc/kernel/group.py b/src/cccc/kernel/group.py index ead3a513..5790cef7 100644 --- a/src/cccc/kernel/group.py +++ b/src/cccc/kernel/group.py @@ -12,6 +12,7 @@ from ..paths import ensure_home from ..util.fs import atomic_write_text from ..util.time import utc_now_iso +from .git import git_branch_exists, git_remote_ref_exists, git_root, git_worktree_add from .registry import Registry from .scope import ScopeIdentity @@ -103,9 +104,90 @@ def create_group(reg: Registry, *, title: str, topic: str = "") -> Group: return Group(group_id=group_id, path=gp, doc=group_doc) -def attach_scope_to_group(reg: Registry, group: Group, scope: ScopeIdentity, *, set_active: bool = True) -> Group: +def attach_scope_to_group( + reg: Registry, + group: Group, + scope: ScopeIdentity, + *, + set_active: bool = True, + create_worktree: bool = False, + worktree_branch: str = "", + base_branch: str = "", +) -> Group: + """Attach a scope (directory) to a group. + + Args: + reg: Registry instance + group: Group to attach the scope to + scope: ScopeIdentity of the directory + set_active: Whether to set this scope as the active scope + create_worktree: If True, create a git worktree for this scope + worktree_branch: Branch name for the worktree (used if base_branch is not provided) + base_branch: Base branch to create a new auto-named branch from (e.g., "main"). + If provided, auto-generates branch name as cccc/. + Takes precedence over worktree_branch. + + Returns: + Updated Group + """ now = utc_now_iso() + # Handle worktree creation if requested + worktree_path = "" + actual_scope_url = scope.url + + if create_worktree: + source_path = Path(scope.url) + repo_root = git_root(source_path) + if not repo_root: + raise ValueError(f"Not a git repository: {scope.url}") + + if base_branch: + # Auto-branch mode: create new branch cccc/ based on base_branch + auto_branch_name = f"cccc/{group.group_id}" + + # Determine start_point based on whether base_branch is already a remote ref + if base_branch.startswith("origin/"): + # User selected a remote branch directly (e.g., "origin/main") + if git_remote_ref_exists(repo_root, base_branch): + start_point = base_branch + else: + raise ValueError(f"Remote branch '{base_branch}' not found") + else: + # User selected a local branch name, prefer origin/ if exists + remote_ref = f"origin/{base_branch}" + if git_remote_ref_exists(repo_root, remote_ref): + start_point = remote_ref + elif git_branch_exists(repo_root, base_branch): + start_point = base_branch + else: + raise ValueError(f"Base branch '{base_branch}' not found (checked origin/{base_branch} and local)") + + # Create worktree with auto-generated branch name + worktree_dir = repo_root.parent / f"{group.group_id}-{auto_branch_name.replace('/', '-')}" + success, msg = git_worktree_add( + repo_root, worktree_dir, auto_branch_name, create_branch=True, start_point=start_point + ) + if not success: + raise ValueError(f"Failed to create worktree: {msg}") + + worktree_path = str(worktree_dir) + actual_scope_url = worktree_path + elif worktree_branch: + # Legacy mode: use provided branch name + # Create worktree in sibling directory: ../group- + worktree_dir = repo_root.parent / f"{group.group_id}-{worktree_branch}" + # Check if branch already exists to decide whether to create it + branch_exists = git_branch_exists(repo_root, worktree_branch) + success, msg = git_worktree_add(repo_root, worktree_dir, worktree_branch, create_branch=not branch_exists) + if not success: + raise ValueError(f"Failed to create worktree: {msg}") + + worktree_path = str(worktree_dir) + actual_scope_url = worktree_path # Update scope URL to point to worktree + else: + raise ValueError("Either worktree_branch or base_branch is required when create_worktree=True") + scopes = group.doc.get("scopes") if not isinstance(scopes, list): scopes = [] @@ -121,9 +203,10 @@ def attach_scope_to_group(reg: Registry, group: Group, scope: ScopeIdentity, *, scope_entry.update( { "scope_key": scope.scope_key, - "url": scope.url, + "url": actual_scope_url, "label": scope.label, "git_remote": scope.git_remote, + "worktree_path": worktree_path, } ) if existing is None: @@ -143,9 +226,10 @@ def attach_scope_to_group(reg: Registry, group: Group, scope: ScopeIdentity, *, scope_doc: Dict[str, Any] = { "v": 1, "scope_key": scope.scope_key, - "url": scope.url, + "url": actual_scope_url, "label": scope.label, "git_remote": scope.git_remote, + "worktree_path": worktree_path, "created_at": created_at, "updated_at": now, } diff --git a/src/cccc/kernel/scope.py b/src/cccc/kernel/scope.py index 3b0e321d..58022ea5 100644 --- a/src/cccc/kernel/scope.py +++ b/src/cccc/kernel/scope.py @@ -14,6 +14,7 @@ class ScopeIdentity: scope_key: str label: str git_remote: str = "" + worktree_path: str = "" # If this scope is a git worktree, path to the worktree def _hash_key(value: str) -> str: diff --git a/src/cccc/ports/web/app.py b/src/cccc/ports/web/app.py index c14afea9..8b6ae914 100644 --- a/src/cccc/ports/web/app.py +++ b/src/cccc/ports/web/app.py @@ -27,6 +27,7 @@ from ...kernel.group import load_group from ...kernel.ledger import read_last_lines from ...kernel.scope import detect_scope +from ...kernel.git import git_list_branches, git_root from ...kernel.prompt_files import ( DEFAULT_PREAMBLE_BODY, DEFAULT_STANDUP_TEMPLATE, @@ -83,6 +84,9 @@ class CreateGroupRequest(BaseModel): class AttachRequest(BaseModel): path: str by: str = Field(default="user") + create_worktree: bool = Field(default=False) + worktree_branch: str = Field(default="") + base_branch: str = Field(default="") class SendRequest(BaseModel): @@ -828,6 +832,50 @@ async def fs_scope_root(path: str = "") -> Dict[str, Any]: except Exception as e: return {"ok": False, "error": {"code": "resolve_failed", "message": str(e)}} + @app.get("/api/v1/git/branches") + async def git_branches(repo_path: str = "", include_remote: bool = False) -> Dict[str, Any]: + """List branches in a git repository with worktree occupation status. + + Returns branches with their status: + - name: branch name + - is_remote: whether it's a remote tracking branch + - in_use: whether it's checked out in a worktree (can't be used for new worktree) + - worktree_path: path to the worktree using this branch, or null + """ + if read_only: + raise HTTPException( + status_code=403, + detail={ + "code": "read_only", + "message": "Git endpoints are disabled in read-only (exhibit) mode.", + "details": {"endpoint": "git_branches"}, + }, + ) + + if not repo_path.strip(): + return {"ok": False, "error": {"code": "missing_path", "message": "repo_path is required"}} + + p = Path(repo_path).expanduser().resolve() + if not p.exists() or not p.is_dir(): + return {"ok": False, "error": {"code": "invalid_path", "message": f"path does not exist: {repo_path}"}} + + # Find git root + root = git_root(p) + if root is None: + return {"ok": False, "error": {"code": "not_git_repo", "message": f"not a git repository: {repo_path}"}} + + try: + branches = await run_in_threadpool(git_list_branches, root, include_remote=include_remote) + return { + "ok": True, + "result": { + "repo_root": str(root), + "branches": branches, + }, + } + except Exception as e: + return {"ok": False, "error": {"code": "list_failed", "message": str(e)}} + @app.get("/api/v1/groups") async def groups() -> Dict[str, Any]: async def _fetch() -> Dict[str, Any]: @@ -1287,7 +1335,16 @@ async def group_settings_update(group_id: str, req: GroupSettingsRequest) -> Dic @app.post("/api/v1/groups/{group_id}/attach") async def group_attach(group_id: str, req: AttachRequest) -> Dict[str, Any]: - return await _daemon({"op": "attach", "args": {"path": req.path, "by": req.by, "group_id": group_id}}) + args: Dict[str, Any] = {"path": req.path, "by": req.by, "group_id": group_id} + if req.create_worktree: + args["create_worktree"] = True + if req.base_branch: + # Auto-branch mode: create cccc/ branch based on base_branch + args["base_branch"] = req.base_branch + else: + # Legacy mode: checkout existing branch + args["worktree_branch"] = req.worktree_branch + return await _daemon({"op": "attach", "args": args}) @app.delete("/api/v1/groups/{group_id}/scopes/{scope_key}") async def group_detach_scope(group_id: str, scope_key: str, by: str = "user") -> Dict[str, Any]: diff --git a/tests/test_git_worktree.py b/tests/test_git_worktree.py new file mode 100644 index 00000000..de15eaa3 --- /dev/null +++ b/tests/test_git_worktree.py @@ -0,0 +1,115 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +class TestGitWorktree(unittest.TestCase): + def setUp(self) -> None: + """Create a temporary git repository for testing.""" + self.temp_dir = tempfile.mkdtemp() + self.repo_root = Path(self.temp_dir) / "test_repo" + self.repo_root.mkdir() + + # Initialize git repo + subprocess.run(["git", "init"], cwd=self.repo_root, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=self.repo_root, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=self.repo_root, + capture_output=True, + ) + + # Create initial commit + (self.repo_root / "README.md").write_text("# Test Repo") + subprocess.run(["git", "add", "."], cwd=self.repo_root, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit"], + cwd=self.repo_root, + capture_output=True, + ) + + def tearDown(self) -> None: + """Clean up temporary directory.""" + import shutil + + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_git_worktree_add_creates_worktree(self) -> None: + from cccc.kernel.git import git_worktree_add + + worktree_path = Path(self.temp_dir) / "worktree_feature" + success, msg = git_worktree_add( + self.repo_root, worktree_path, "feature-branch", create_branch=True + ) + + self.assertTrue(success, f"Failed to create worktree: {msg}") + self.assertTrue(worktree_path.exists()) + self.assertTrue((worktree_path / ".git").exists()) + self.assertTrue((worktree_path / "README.md").exists()) + + def test_git_worktree_add_existing_branch(self) -> None: + from cccc.kernel.git import git_worktree_add + + # Create a branch first + subprocess.run( + ["git", "branch", "existing-branch"], + cwd=self.repo_root, + capture_output=True, + ) + + worktree_path = Path(self.temp_dir) / "worktree_existing" + success, msg = git_worktree_add( + self.repo_root, worktree_path, "existing-branch", create_branch=False + ) + + self.assertTrue(success, f"Failed to create worktree: {msg}") + self.assertTrue(worktree_path.exists()) + + def test_git_worktree_list_returns_worktrees(self) -> None: + from cccc.kernel.git import git_worktree_add, git_worktree_list + + # Create a worktree first + worktree_path = Path(self.temp_dir) / "worktree_list_test" + git_worktree_add(self.repo_root, worktree_path, "list-test-branch") + + worktrees = git_worktree_list(self.repo_root) + + self.assertGreaterEqual(len(worktrees), 2) # main + new worktree + # Resolve paths to handle symlinks (e.g., /tmp -> /private/tmp on macOS) + paths = [Path(w.get("path", "")).resolve() for w in worktrees] + self.assertIn(worktree_path.resolve(), paths) + + def test_git_worktree_remove_deletes_worktree(self) -> None: + from cccc.kernel.git import git_worktree_add, git_worktree_remove + + worktree_path = Path(self.temp_dir) / "worktree_to_remove" + git_worktree_add(self.repo_root, worktree_path, "remove-test-branch") + self.assertTrue(worktree_path.exists()) + + success, msg = git_worktree_remove(self.repo_root, worktree_path) + + self.assertTrue(success, f"Failed to remove worktree: {msg}") + self.assertFalse(worktree_path.exists()) + + def test_git_is_worktree_detects_worktree(self) -> None: + from cccc.kernel.git import git_is_worktree, git_worktree_add + + # Main repo should not be detected as a worktree (has .git directory) + self.assertFalse(git_is_worktree(self.repo_root)) + + # Create a worktree + worktree_path = Path(self.temp_dir) / "worktree_detect" + git_worktree_add(self.repo_root, worktree_path, "detect-branch") + + # Worktree should be detected (has .git file, not directory) + self.assertTrue(git_is_worktree(worktree_path)) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/components/AppModals.tsx b/web/src/components/AppModals.tsx index 13909e8b..6e2e1ff0 100644 --- a/web/src/components/AppModals.tsx +++ b/web/src/components/AppModals.tsx @@ -118,6 +118,10 @@ export function AppModals({ createGroupPath, createGroupName, createGroupTemplateFile, + createWorktree, + baseBranch, + branches, + loadingBranches, dirItems, dirSuggestions, currentDir, @@ -126,6 +130,10 @@ export function AppModals({ setCreateGroupPath, setCreateGroupName, setCreateGroupTemplateFile, + setCreateWorktree, + setBaseBranch, + setBranches, + setLoadingBranches, setDirItems, setCurrentDir, setParentDir, @@ -181,6 +189,38 @@ export function AppModals({ return () => window.clearTimeout(t); }, [modals.createGroup, createGroupTemplateFile, createGroupPath]); + // Fetch branches when worktree option is enabled and path is set + useEffect(() => { + if (!modals.createGroup || !createWorktree || !createGroupPath.trim()) { + setBranches([]); + return; + } + const t = window.setTimeout(() => { + void (async () => { + setLoadingBranches(true); + try { + const resp = await api.fetchBranches(createGroupPath); + if (resp.ok && resp.result?.branches) { + const branchList = resp.result.branches; + setBranches(branchList); + // Auto-select current branch if available (current branch is valid base even if in_use) + const currentBranch = branchList.find(b => b.is_current); + if (currentBranch) { + setBaseBranch(currentBranch.name); + } + } else { + setBranches([]); + } + } catch { + setBranches([]); + } finally { + setLoadingBranches(false); + } + })(); + }, 300); + return () => window.clearTimeout(t); + }, [modals.createGroup, createWorktree, createGroupPath]); + // Computed const selectedGroupRunning = useGroupStore( (s) => s.groups.find((g) => String(g.group_id || "") === s.selectedGroupId)?.running ?? false @@ -426,7 +466,10 @@ export function AppModals({ return; } groupId = resp.result.group_id; - const attachResp = await api.attachScope(groupId, path); + const attachResp = await api.attachScope(groupId, path, { + createWorktree, + baseBranch, + }); if (!attachResp.ok) { showError(`Created group but failed to attach: ${attachResp.error.message}`); } @@ -719,6 +762,12 @@ export function AppModals({ templateError={createTemplateError} templateBusy={createTemplateBusy} onSelectTemplate={handleSelectCreateGroupTemplate} + createWorktree={createWorktree} + setCreateWorktree={setCreateWorktree} + baseBranch={baseBranch} + setBaseBranch={setBaseBranch} + branches={branches} + loadingBranches={loadingBranches} onFetchDirContents={handleFetchDirContents} onCreateGroup={handleCreateGroup} onClose={() => closeModal("createGroup")} diff --git a/web/src/components/modals/CreateGroupModal.tsx b/web/src/components/modals/CreateGroupModal.tsx index 404d6aec..11dcfc49 100644 --- a/web/src/components/modals/CreateGroupModal.tsx +++ b/web/src/components/modals/CreateGroupModal.tsx @@ -1,4 +1,5 @@ import { DirItem, DirSuggestion } from "../../types"; +import { BranchInfo } from "../../services/api"; import { TemplatePreviewDetails } from "../TemplatePreviewDetails"; export interface CreateGroupModalProps { @@ -24,6 +25,14 @@ export interface CreateGroupModalProps { templateBusy: boolean; onSelectTemplate: (file: File | null) => void; + // Worktree options + createWorktree: boolean; + setCreateWorktree: (v: boolean) => void; + baseBranch: string; + setBaseBranch: (v: string) => void; + branches: BranchInfo[]; + loadingBranches: boolean; + onFetchDirContents: (path: string) => void; onCreateGroup: () => void; onClose: () => void; @@ -50,6 +59,12 @@ export function CreateGroupModal({ templateError, templateBusy, onSelectTemplate, + createWorktree, + setCreateWorktree, + baseBranch, + setBaseBranch, + branches, + loadingBranches, onFetchDirContents, onCreateGroup, onClose, @@ -198,6 +213,72 @@ export function CreateGroupModal({ /> +
+ +
+ + {createWorktree && ( +
+ {loadingBranches ? ( +
+ Loading branches... +
+ ) : branches.length > 0 ? ( +
+ + + {baseBranch && ( +
+ A new branch will be auto-created based on "{baseBranch}" +
+ )} +
+ ) : ( +
+ No branches found. Make sure the directory is a Git repository. +
+ )} +
+ )} +
+ Creates a separate working directory to avoid conflicts with other groups. +
+
+
+