From 37763c371658078dd128709d33aba25409159347 Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Wed, 20 Aug 2025 22:18:53 +0100 Subject: [PATCH 1/6] fixed clone repo --- examples/hf_demo_space/app.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/examples/hf_demo_space/app.py b/examples/hf_demo_space/app.py index 00cf415..7079857 100644 --- a/examples/hf_demo_space/app.py +++ b/examples/hf_demo_space/app.py @@ -10,6 +10,7 @@ from codetide.agents.tide.ui.utils import run_concurrent_tasks from codetide.agents.tide.ui.agent_tide_ui import AgentTideUi from codetide.core.defaults import DEFAULT_ENCODING +from codetide.core.logs import logger from codetide.agents.tide.models import Step from aicore.const import STREAM_END_TOKEN, STREAM_START_TOKEN @@ -24,8 +25,9 @@ import chainlit as cl import subprocess import shutil -import yaml import json +import stat +import yaml import os import re @@ -40,6 +42,7 @@ def validate_git_url(url) -> None: """Validate the Git repository URL using git ls-remote.""" + if not GIT_URL_PATTERN.match(url): raise ValueError(f"Invalid Git repository URL format: {url}") @@ -141,22 +144,25 @@ async def clone_repo(session_id): while exception: try: - url = await cl.AskUserMessage( + user_message = await cl.AskUserMessage( content="Provide a valid github url to give AgentTide some context!" ).send() + url = user_message.get("output") validate_git_url(url) exception = None except Exception as e: await cl.Message(f"Invalid url found, please provide only the url, if it is a private repo you can inlucde a PAT in the url: {e}").send() exception = e + logger.info(f"executing cmd git clone --no-checkout {url} {DEFAULT_SESSIONS_WORKSPACE / session_id}") subprocess.run( - ["git", "clone", "--no-checkout", url, DEFAULT_SESSIONS_WORKSPACE / session_id], + ["git", "clone", url, DEFAULT_SESSIONS_WORKSPACE / session_id], check=True, capture_output=True, text=True, timeout=300 ) + logger.info(f"finished cloning to {DEFAULT_SESSIONS_WORKSPACE / session_id}") @cl.on_chat_start @@ -178,10 +184,15 @@ async def empty_current_session(): if os.path.exists(session_path): shutil.rmtree(session_path) +def remove_readonly(func, path, _): + """Clear the readonly bit and reattempt the removal""" + os.chmod(path, stat.S_IWRITE) + func(path) + @cl.on_app_shutdown async def empty_all_sessions(): if os.path.exists(DEFAULT_SESSIONS_WORKSPACE): - shutil.rmtree(DEFAULT_SESSIONS_WORKSPACE) + shutil.rmtree(DEFAULT_SESSIONS_WORKSPACE, onexc=remove_readonly) @cl.action_callback("execute_steps") async def on_execute_steps(action :cl.Action): From beb962555379872ee24a9627b40ca67c6c1d51a1 Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Wed, 20 Aug 2025 22:42:07 +0100 Subject: [PATCH 2/6] made subprocesses async --- examples/hf_demo_space/app.py | 50 ++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/examples/hf_demo_space/app.py b/examples/hf_demo_space/app.py index 7079857..06ad6db 100644 --- a/examples/hf_demo_space/app.py +++ b/examples/hf_demo_space/app.py @@ -24,6 +24,7 @@ from ulid import ulid import chainlit as cl import subprocess +import asyncio import shutil import json import stat @@ -40,23 +41,30 @@ r'(?:[:/])([^/]+/[^/]+?)(?:\.git)?$' # Repo path ) -def validate_git_url(url) -> None: +async def validate_git_url(url) -> None: """Validate the Git repository URL using git ls-remote.""" if not GIT_URL_PATTERN.match(url): raise ValueError(f"Invalid Git repository URL format: {url}") try: - result = subprocess.run( - ["git", "ls-remote", url], - capture_output=True, - text=True, - check=True, - timeout=10 # Add timeout to prevent hanging + process = await asyncio.create_subprocess_exec( + "git", "ls-remote", url, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE ) - if not result.stdout.strip(): + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "ls-remote", url], stdout, stderr) + + if not stdout.strip(): raise ValueError(f"URL {url} points to an empty repository") - except subprocess.TimeoutExpired: + + except asyncio.TimeoutError: + process.kill() + await process.wait() raise ValueError(f"Timeout while validating URL {url}") except subprocess.CalledProcessError as e: raise ValueError(f"Invalid Git repository URL: {url}. Error: {e.stderr}") from e @@ -148,20 +156,30 @@ async def clone_repo(session_id): content="Provide a valid github url to give AgentTide some context!" ).send() url = user_message.get("output") - validate_git_url(url) + await validate_git_url(url) exception = None except Exception as e: await cl.Message(f"Invalid url found, please provide only the url, if it is a private repo you can inlucde a PAT in the url: {e}").send() exception = e logger.info(f"executing cmd git clone --no-checkout {url} {DEFAULT_SESSIONS_WORKSPACE / session_id}") - subprocess.run( - ["git", "clone", url, DEFAULT_SESSIONS_WORKSPACE / session_id], - check=True, - capture_output=True, - text=True, - timeout=300 + + process = await asyncio.create_subprocess_exec( + "git", "clone", url, str(DEFAULT_SESSIONS_WORKSPACE / session_id), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE ) + + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300) + except asyncio.TimeoutError: + process.kill() + await process.wait() + raise + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "clone", url], stdout, stderr) + logger.info(f"finished cloning to {DEFAULT_SESSIONS_WORKSPACE / session_id}") From 59dde667599ef4e47cb72c625f75558e331ec49f Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Wed, 20 Aug 2025 22:43:29 +0100 Subject: [PATCH 3/6] updated .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 37205fb..d652275 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,5 @@ config/ .files/ codetide/agents/tide/ui/assets/ +examples/hf_demo_space/.chainlit/* +examples/hf_demo_space/chainlit.md From a7da57e310a4defb1444b94e3234b9dcedcc0746 Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Wed, 20 Aug 2025 23:07:40 +0100 Subject: [PATCH 4/6] added button to checkout and push --- examples/hf_demo_space/app.py | 50 ++++-------- examples/hf_demo_space/git_utils.py | 117 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 37 deletions(-) create mode 100644 examples/hf_demo_space/git_utils.py diff --git a/examples/hf_demo_space/app.py b/examples/hf_demo_space/app.py index 06ad6db..851f763 100644 --- a/examples/hf_demo_space/app.py +++ b/examples/hf_demo_space/app.py @@ -18,8 +18,9 @@ from aicore.config import Config from aicore.llm import Llm -from typing import Optional +from git_utils import commit_and_push_changes, validate_git_url from chainlit.cli import run_chainlit +from typing import Optional from pathlib import Path from ulid import ulid import chainlit as cl @@ -30,45 +31,9 @@ import stat import yaml import os -import re DEFAULT_SESSIONS_WORKSPACE = Path(os.getcwd()) / "sessions" -GIT_URL_PATTERN = re.compile( - r'^(?:http|https|git|ssh)://' # Protocol - r'(?:\S+@)?' # Optional username - r'([^/]+)' # Domain - r'(?:[:/])([^/]+/[^/]+?)(?:\.git)?$' # Repo path -) - -async def validate_git_url(url) -> None: - """Validate the Git repository URL using git ls-remote.""" - - if not GIT_URL_PATTERN.match(url): - raise ValueError(f"Invalid Git repository URL format: {url}") - - try: - process = await asyncio.create_subprocess_exec( - "git", "ls-remote", url, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10) - - if process.returncode != 0: - raise subprocess.CalledProcessError(process.returncode, ["git", "ls-remote", url], stdout, stderr) - - if not stdout.strip(): - raise ValueError(f"URL {url} points to an empty repository") - - except asyncio.TimeoutError: - process.kill() - await process.wait() - raise ValueError(f"Timeout while validating URL {url}") - except subprocess.CalledProcessError as e: - raise ValueError(f"Invalid Git repository URL: {url}. Error: {e.stderr}") from e - async def validate_llm_config_hf(agent_tide_ui: AgentTideUi): exception = True session_id = cl.user_session.get("session_id") @@ -278,6 +243,11 @@ async def on_stop_steps(action :cl.Action): agent_tide_ui.current_step = None await task_list.remove() +@cl.action_callback("checkout_commit_push") +async def on_checkout_commit_push(action :cl.Action): + session_id = cl.user_session.get("session_id") + await commit_and_push_changes(DEFAULT_SESSIONS_WORKSPACE / session_id) + @cl.on_message async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None): agent_tide_ui = await loadAgentTideUi() @@ -343,6 +313,12 @@ async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None tooltip="Next step", icon="fast-forward", payload={"msg_id": msg.id} + ), + cl.Action( + name="checkout_commit_push", + tooltip="A new branch will be created and the changes made so far will be commited and pushed to the upstream repository", + icon="circle-fading-arrow-up", + payload={"msg_id": msg.id} ) ] diff --git a/examples/hf_demo_space/git_utils.py b/examples/hf_demo_space/git_utils.py new file mode 100644 index 0000000..20967de --- /dev/null +++ b/examples/hf_demo_space/git_utils.py @@ -0,0 +1,117 @@ +import asyncio +from pathlib import Path +import re +import subprocess + + +GIT_URL_PATTERN = re.compile( + r'^(?:http|https|git|ssh)://' # Protocol + r'(?:\S+@)?' # Optional username + r'([^/]+)' # Domain + r'(?:[:/])([^/]+/[^/]+?)(?:\.git)?$' # Repo path +) + +async def validate_git_url(url) -> None: + """Validate the Git repository URL using git ls-remote.""" + + if not GIT_URL_PATTERN.match(url): + raise ValueError(f"Invalid Git repository URL format: {url}") + + try: + process = await asyncio.create_subprocess_exec( + "git", "ls-remote", url, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "ls-remote", url], stdout, stderr) + + if not stdout.strip(): + raise ValueError(f"URL {url} points to an empty repository") + + except asyncio.TimeoutError: + process.kill() + await process.wait() + raise ValueError(f"Timeout while validating URL {url}") + except subprocess.CalledProcessError as e: + raise ValueError(f"Invalid Git repository URL: {url}. Error: {e.stderr}") from e + +async def commit_and_push_changes(repo_path: Path, branch_name: str = None, commit_message: str = "Auto-commit: Save changes") -> None: + """Add all changes, commit with default message, and push to remote.""" + + repo_path_str = str(repo_path) + + try: + # Create new branch with Agent Tide + ULID name if not provided + if not branch_name: + import ulid + branch_name = f"agent-tide-{ulid.new()}" + + # Create and checkout new branch + process = await asyncio.create_subprocess_exec( + "git", "checkout", "-b", branch_name, + cwd=repo_path_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + text=True + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "checkout", "-b", branch_name], stdout, stderr) + + # Add all changes + process = await asyncio.create_subprocess_exec( + "git", "add", ".", + cwd=repo_path_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + text=True + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "add", "."], stdout, stderr) + + # Commit changes + process = await asyncio.create_subprocess_exec( + "git", "commit", "-m", commit_message, + cwd=repo_path_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + text=True + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) + + if process.returncode != 0: + # Check if it's because there are no changes to commit + if "nothing to commit" in stderr or "nothing to commit" in stdout: + return # No changes to commit, exit gracefully + raise subprocess.CalledProcessError(process.returncode, ["git", "commit", "-m", commit_message], stdout, stderr) + + # Push to remote + process = await asyncio.create_subprocess_exec( + "git", "push", "origin", branch_name, + cwd=repo_path_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + text=True + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "push", "origin", branch_name], stdout, stderr) + + except asyncio.TimeoutError: + process.kill() + await process.wait() + raise ValueError(f"Timeout during git operation in {repo_path}") + except subprocess.CalledProcessError as e: + raise ValueError(f"Git operation failed in {repo_path}. Error: {e.stderr}") from e From 4c2ec951a8c23b5ba909fc303e18101b59ae3ed5 Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Wed, 20 Aug 2025 23:08:03 +0100 Subject: [PATCH 5/6] added sessions dir with relevant permissions to dockerfile --- examples/hf_demo_space/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/hf_demo_space/Dockerfile b/examples/hf_demo_space/Dockerfile index a9c2146..fd22816 100644 --- a/examples/hf_demo_space/Dockerfile +++ b/examples/hf_demo_space/Dockerfile @@ -17,6 +17,7 @@ RUN mkdir -p /app/.files && chmod 777 /app/.files RUN mkdir -p /app/logs && chmod 777 /app/logs RUN mkdir -p /app/observability_data && chmod 777 /app/observability_data RUN mkdir -p /app/storage && chmod 777 /app/storage +RUN mkdir -p /app/sessions && chmod 777 /app/sessions # Copy the current repository into the container COPY . /app From 5d4ead71fea4d31c1fbf10de5f86461620352eb2 Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Wed, 20 Aug 2025 23:19:30 +0100 Subject: [PATCH 6/6] added placeholder _handle_commands --- codetide/agents/tide/agent.py | 7 ++++++- codetide/agents/tide/ui/agent_tide_ui.py | 6 ++++-- codetide/agents/tide/ui/app.py | 2 +- examples/hf_demo_space/app.py | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/codetide/agents/tide/agent.py b/codetide/agents/tide/agent.py index 184963d..967bc8e 100644 --- a/codetide/agents/tide/agent.py +++ b/codetide/agents/tide/agent.py @@ -169,4 +169,9 @@ def _(event): # This can happen if the event loop is shut down pass finally: - _logger.logger.info("Exited by user. Goodbye!") \ No newline at end of file + _logger.logger.info("Exited by user. Goodbye!") + + async def _handle_commands(self, command :str): + # TODO add logic here to handlle git command, i.e stage files, write commit messages and checkout + # expand to support new branches + pass \ No newline at end of file diff --git a/codetide/agents/tide/ui/agent_tide_ui.py b/codetide/agents/tide/ui/agent_tide_ui.py index 553a6e5..00afceb 100644 --- a/codetide/agents/tide/ui/agent_tide_ui.py +++ b/codetide/agents/tide/ui/agent_tide_ui.py @@ -126,5 +126,7 @@ def settings(self): ) ] - def get_command_prompt(self, command :str)->Optional[str]: - return self.commands_prompts.get(command) + async def get_command_prompt(self, command :str)->Optional[str]: + await self.agent_tide._handle_commands(command) + + return self.commands_prompts.get(command) diff --git a/codetide/agents/tide/ui/app.py b/codetide/agents/tide/ui/app.py index 7c6b63f..ac9acb3 100644 --- a/codetide/agents/tide/ui/app.py +++ b/codetide/agents/tide/ui/app.py @@ -240,7 +240,7 @@ async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None chat_history = cl.user_session.get("chat_history") if message.command: - command_prompt = agent_tide_ui.get_command_prompt(message.command) + command_prompt = await agent_tide_ui.get_command_prompt(message.command) if command_prompt: message.content = "\n\n---\n\n".join([command_prompt, message.content]) diff --git a/examples/hf_demo_space/app.py b/examples/hf_demo_space/app.py index 851f763..a28991c 100644 --- a/examples/hf_demo_space/app.py +++ b/examples/hf_demo_space/app.py @@ -255,7 +255,7 @@ async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None chat_history = cl.user_session.get("chat_history") if message.command: - command_prompt = agent_tide_ui.get_command_prompt(message.command) + command_prompt = await agent_tide_ui.get_command_prompt(message.command) if command_prompt: message.content = "\n\n---\n\n".join([command_prompt, message.content])