Cleanup dead code (#1336)
* Remove unused delete file endpoint, move test helpers to test files Fix missing git id in tests * Remove unused gh cli methods * Remove unused replace_process * Fix open editor exports, remove unused struct Fix compile * Remove unused get_tasks endpoint Re-add get tasks, used by mcp * Remove unused get_execution_processes endpoint * Remove unused get tag endpoint
This commit is contained in:
@@ -82,6 +82,9 @@ fn generate_types_content() -> String {
|
||||
server::routes::task_attempts::ChangeTargetBranchResponse::decl(),
|
||||
server::routes::task_attempts::RenameBranchRequest::decl(),
|
||||
server::routes::task_attempts::RenameBranchResponse::decl(),
|
||||
server::routes::task_attempts::CommitCompareResult::decl(),
|
||||
server::routes::task_attempts::OpenEditorRequest::decl(),
|
||||
server::routes::task_attempts::OpenEditorResponse::decl(),
|
||||
server::routes::shared_tasks::AssignSharedTaskRequest::decl(),
|
||||
server::routes::shared_tasks::AssignSharedTaskResponse::decl(),
|
||||
server::routes::tasks::ShareTaskResponse::decl(),
|
||||
@@ -101,7 +104,6 @@ fn generate_types_content() -> String {
|
||||
services::services::git::GitBranch::decl(),
|
||||
utils::diff::Diff::decl(),
|
||||
utils::diff::DiffChangeKind::decl(),
|
||||
services::services::github::RepositoryInfo::decl(),
|
||||
executors::command::CommandBuilder::decl(),
|
||||
executors::profile::ExecutorProfileId::decl(),
|
||||
executors::profile::ExecutorConfig::decl(),
|
||||
@@ -130,7 +132,6 @@ fn generate_types_content() -> String {
|
||||
server::routes::task_attempts::gh_cli_setup::GhCliSetupError::decl(),
|
||||
server::routes::task_attempts::RebaseTaskAttemptRequest::decl(),
|
||||
server::routes::task_attempts::GitOperationError::decl(),
|
||||
server::routes::task_attempts::ReplaceProcessRequest::decl(),
|
||||
server::routes::task_attempts::CommitInfo::decl(),
|
||||
server::routes::task_attempts::BranchStatus::decl(),
|
||||
services::services::git::ConflictOp::decl(),
|
||||
|
||||
@@ -29,21 +29,6 @@ pub struct ExecutionProcessQuery {
|
||||
pub show_soft_deleted: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn get_execution_processes(
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
Query(query): Query<ExecutionProcessQuery>,
|
||||
) -> Result<ResponseJson<ApiResponse<Vec<ExecutionProcess>>>, ApiError> {
|
||||
let pool = &deployment.db().pool;
|
||||
let execution_processes = ExecutionProcess::find_by_task_attempt_id(
|
||||
pool,
|
||||
query.task_attempt_id,
|
||||
query.show_soft_deleted.unwrap_or(false),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ResponseJson(ApiResponse::success(execution_processes)))
|
||||
}
|
||||
|
||||
pub async fn get_execution_process_by_id(
|
||||
Extension(execution_process): Extension<ExecutionProcess>,
|
||||
State(_deployment): State<DeploymentImpl>,
|
||||
@@ -259,7 +244,6 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
||||
));
|
||||
|
||||
let task_attempts_router = Router::new()
|
||||
.route("/", get(get_execution_processes))
|
||||
.route("/stream/ws", get(stream_execution_processes_ws))
|
||||
.nest("/{id}", task_attempt_id_router);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use axum::{
|
||||
extract::{Query, State},
|
||||
middleware::from_fn_with_state,
|
||||
response::Json as ResponseJson,
|
||||
routing::get,
|
||||
routing::{get, put},
|
||||
};
|
||||
use db::models::tag::{CreateTag, Tag, UpdateTag};
|
||||
use deployment::Deployment;
|
||||
@@ -34,12 +34,6 @@ pub async fn get_tags(
|
||||
Ok(ResponseJson(ApiResponse::success(tags)))
|
||||
}
|
||||
|
||||
pub async fn get_tag(
|
||||
Extension(tag): Extension<Tag>,
|
||||
) -> Result<ResponseJson<ApiResponse<Tag>>, ApiError> {
|
||||
Ok(Json(ApiResponse::success(tag)))
|
||||
}
|
||||
|
||||
pub async fn create_tag(
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
Json(payload): Json<CreateTag>,
|
||||
@@ -93,7 +87,7 @@ pub async fn delete_tag(
|
||||
|
||||
pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
||||
let tag_router = Router::new()
|
||||
.route("/", get(get_tag).put(update_tag).delete(delete_tag))
|
||||
.route("/", put(update_tag).delete(delete_tag))
|
||||
.layer(from_fn_with_state(deployment.clone(), load_tag_middleware));
|
||||
|
||||
let inner = Router::new()
|
||||
|
||||
@@ -68,29 +68,6 @@ pub enum GitOperationError {
|
||||
RebaseInProgress,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
pub struct ReplaceProcessRequest {
|
||||
/// Process to replace (delete this and later ones)
|
||||
pub process_id: Uuid,
|
||||
/// New prompt to use for the replacement follow-up
|
||||
pub prompt: String,
|
||||
/// Optional variant override
|
||||
pub variant: Option<String>,
|
||||
/// If true, allow resetting Git even when uncommitted changes exist
|
||||
pub force_when_dirty: Option<bool>,
|
||||
/// If false, skip performing the Git reset step (history drop still applies)
|
||||
pub perform_git_reset: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, TS)]
|
||||
pub struct ReplaceProcessResult {
|
||||
pub deleted_count: i64,
|
||||
pub git_reset_needed: bool,
|
||||
pub git_reset_applied: bool,
|
||||
pub target_before_oid: Option<String>,
|
||||
pub new_execution_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
pub struct CreateGitHubPrRequest {
|
||||
pub title: String,
|
||||
@@ -98,13 +75,6 @@ pub struct CreateGitHubPrRequest {
|
||||
pub target_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FollowUpResponse {
|
||||
pub message: String,
|
||||
pub actual_attempt_id: Uuid,
|
||||
pub created_new_attempt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TaskAttemptQuery {
|
||||
pub task_id: Option<Uuid>,
|
||||
@@ -394,135 +364,6 @@ pub async fn follow_up(
|
||||
Ok(ResponseJson(ApiResponse::success(execution_process)))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn replace_process(
|
||||
Extension(task_attempt): Extension<TaskAttempt>,
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
Json(payload): Json<ReplaceProcessRequest>,
|
||||
) -> Result<ResponseJson<ApiResponse<ReplaceProcessResult>>, ApiError> {
|
||||
let pool = &deployment.db().pool;
|
||||
let proc_id = payload.process_id;
|
||||
let force_when_dirty = payload.force_when_dirty.unwrap_or(false);
|
||||
let perform_git_reset = payload.perform_git_reset.unwrap_or(true);
|
||||
|
||||
// Validate process belongs to attempt
|
||||
let process =
|
||||
ExecutionProcess::find_by_id(pool, proc_id)
|
||||
.await?
|
||||
.ok_or(ApiError::TaskAttempt(TaskAttemptError::ValidationError(
|
||||
"Process not found".to_string(),
|
||||
)))?;
|
||||
if process.task_attempt_id != task_attempt.id {
|
||||
return Err(ApiError::TaskAttempt(TaskAttemptError::ValidationError(
|
||||
"Process does not belong to this attempt".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Determine target reset OID: before the target process
|
||||
let mut target_before_oid = process.before_head_commit.clone();
|
||||
if target_before_oid.is_none() {
|
||||
// Fallback: previous process's after_head_commit
|
||||
target_before_oid =
|
||||
ExecutionProcess::find_prev_after_head_commit(pool, task_attempt.id, proc_id).await?;
|
||||
}
|
||||
|
||||
// Decide if Git reset is needed and apply it
|
||||
let mut git_reset_needed = false;
|
||||
let mut git_reset_applied = false;
|
||||
if let Some(target_oid) = &target_before_oid {
|
||||
let wt_buf = ensure_worktree_path(&deployment, &task_attempt).await?;
|
||||
let wt = wt_buf.as_path();
|
||||
let is_dirty = deployment
|
||||
.container()
|
||||
.is_container_clean(&task_attempt)
|
||||
.await
|
||||
.map(|is_clean| !is_clean)
|
||||
.unwrap_or(false);
|
||||
|
||||
let outcome = deployment.git().reconcile_worktree_to_commit(
|
||||
wt,
|
||||
target_oid,
|
||||
WorktreeResetOptions::new(perform_git_reset, force_when_dirty, is_dirty, false),
|
||||
);
|
||||
git_reset_needed = outcome.needed;
|
||||
git_reset_applied = outcome.applied;
|
||||
}
|
||||
|
||||
// Stop any running processes for this attempt
|
||||
deployment.container().try_stop(&task_attempt).await;
|
||||
|
||||
// Soft-drop the target process and all later processes
|
||||
let deleted_count = ExecutionProcess::drop_at_and_after(pool, task_attempt.id, proc_id).await?;
|
||||
|
||||
// Build follow-up executor action using the original process profile
|
||||
let initial_executor_profile_id = match &process
|
||||
.executor_action()
|
||||
.map_err(|e| ApiError::TaskAttempt(TaskAttemptError::ValidationError(e.to_string())))?
|
||||
.typ
|
||||
{
|
||||
ExecutorActionType::CodingAgentInitialRequest(request) => {
|
||||
Ok(request.executor_profile_id.clone())
|
||||
}
|
||||
ExecutorActionType::CodingAgentFollowUpRequest(request) => {
|
||||
Ok(request.executor_profile_id.clone())
|
||||
}
|
||||
_ => Err(ApiError::TaskAttempt(TaskAttemptError::ValidationError(
|
||||
"Couldn't find profile from executor action".to_string(),
|
||||
))),
|
||||
}?;
|
||||
|
||||
let executor_profile_id = ExecutorProfileId {
|
||||
executor: initial_executor_profile_id.executor,
|
||||
variant: payload
|
||||
.variant
|
||||
.or(initial_executor_profile_id.variant.clone()),
|
||||
};
|
||||
|
||||
// Use latest session_id from remaining (earlier) processes; if none exists, start a fresh initial request
|
||||
let latest_session_id =
|
||||
ExecutionProcess::find_latest_session_id_by_task_attempt(pool, task_attempt.id).await?;
|
||||
|
||||
let action = if let Some(session_id) = latest_session_id {
|
||||
let follow_up_request = CodingAgentFollowUpRequest {
|
||||
prompt: payload.prompt.clone(),
|
||||
session_id,
|
||||
executor_profile_id,
|
||||
};
|
||||
ExecutorAction::new(
|
||||
ExecutorActionType::CodingAgentFollowUpRequest(follow_up_request),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
// No prior session (e.g., replacing the first run) → start a fresh initial request
|
||||
ExecutorAction::new(
|
||||
ExecutorActionType::CodingAgentInitialRequest(
|
||||
executors::actions::coding_agent_initial::CodingAgentInitialRequest {
|
||||
prompt: payload.prompt.clone(),
|
||||
executor_profile_id,
|
||||
},
|
||||
),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let execution_process = deployment
|
||||
.container()
|
||||
.start_execution(
|
||||
&task_attempt,
|
||||
&action,
|
||||
&ExecutionProcessRunReason::CodingAgent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ResponseJson(ApiResponse::success(ReplaceProcessResult {
|
||||
deleted_count,
|
||||
git_reset_needed,
|
||||
git_reset_applied,
|
||||
target_before_oid,
|
||||
new_execution_id: Some(execution_process.id),
|
||||
})))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn stream_task_attempt_diff_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
@@ -893,7 +734,7 @@ pub async fn create_github_pr(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(serde::Deserialize, TS)]
|
||||
pub struct OpenEditorRequest {
|
||||
editor_type: Option<String>,
|
||||
file_path: Option<String>,
|
||||
@@ -907,14 +748,14 @@ pub struct OpenEditorResponse {
|
||||
pub async fn open_task_attempt_in_editor(
|
||||
Extension(task_attempt): Extension<TaskAttempt>,
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
Json(payload): Json<Option<OpenEditorRequest>>,
|
||||
Json(payload): Json<OpenEditorRequest>,
|
||||
) -> Result<ResponseJson<ApiResponse<OpenEditorResponse>>, ApiError> {
|
||||
// Get the task attempt to access the worktree path
|
||||
let base_path_buf = ensure_worktree_path(&deployment, &task_attempt).await?;
|
||||
let base_path = base_path_buf.as_path();
|
||||
|
||||
// If a specific file path is provided, use it; otherwise use the base path
|
||||
let path = if let Some(file_path) = payload.as_ref().and_then(|req| req.file_path.as_ref()) {
|
||||
let path = if let Some(file_path) = payload.file_path.as_ref() {
|
||||
base_path.join(file_path)
|
||||
} else {
|
||||
base_path.to_path_buf()
|
||||
@@ -922,7 +763,7 @@ pub async fn open_task_attempt_in_editor(
|
||||
|
||||
let editor_config = {
|
||||
let config = deployment.config().read().await;
|
||||
let editor_type_str = payload.as_ref().and_then(|req| req.editor_type.as_deref());
|
||||
let editor_type_str = payload.editor_type.as_deref();
|
||||
config.editor.with_override(editor_type_str)
|
||||
};
|
||||
|
||||
@@ -940,7 +781,7 @@ pub async fn open_task_attempt_in_editor(
|
||||
"task_attempt_editor_opened",
|
||||
serde_json::json!({
|
||||
"attempt_id": task_attempt.id.to_string(),
|
||||
"editor_type": payload.as_ref().and_then(|req| req.editor_type.as_ref()),
|
||||
"editor_type": payload.editor_type.as_ref(),
|
||||
"remote_mode": url.is_some(),
|
||||
}),
|
||||
)
|
||||
@@ -1375,40 +1216,6 @@ pub async fn abort_conflicts_task_attempt(
|
||||
Ok(ResponseJson(ApiResponse::success(())))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct DeleteFileQuery {
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn delete_task_attempt_file(
|
||||
Extension(task_attempt): Extension<TaskAttempt>,
|
||||
Query(query): Query<DeleteFileQuery>,
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
) -> Result<ResponseJson<ApiResponse<()>>, ApiError> {
|
||||
let container_ref = deployment
|
||||
.container()
|
||||
.ensure_container_exists(&task_attempt)
|
||||
.await?;
|
||||
let worktree_path = std::path::Path::new(&container_ref);
|
||||
|
||||
// Use GitService to delete file and commit
|
||||
let _commit_id = deployment
|
||||
.git()
|
||||
.delete_file_and_commit(worktree_path, &query.file_path)
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"Failed to delete file '{}' from task attempt {}: {}",
|
||||
query.file_path,
|
||||
task_attempt.id,
|
||||
e
|
||||
);
|
||||
ApiError::GitService(e)
|
||||
})?;
|
||||
|
||||
Ok(ResponseJson(ApiResponse::success(())))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn start_dev_server(
|
||||
Extension(task_attempt): Extension<TaskAttempt>,
|
||||
@@ -1701,7 +1508,6 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
||||
.delete(drafts::delete_draft),
|
||||
)
|
||||
.route("/draft/queue", post(drafts::set_draft_queue))
|
||||
.route("/replace-process", post(replace_process))
|
||||
.route("/commit-info", get(get_commit_info))
|
||||
.route("/commit-compare", get(compare_commit_to_head))
|
||||
.route("/start-dev-server", post(start_dev_server))
|
||||
@@ -1714,7 +1520,6 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
||||
.route("/pr", post(create_github_pr))
|
||||
.route("/pr/attach", post(attach_existing_pr))
|
||||
.route("/open-editor", post(open_task_attempt_in_editor))
|
||||
.route("/delete-file", post(delete_task_attempt_file))
|
||||
.route("/children", get(get_task_attempt_children))
|
||||
.route("/stop", post(stop_task_attempt_execution))
|
||||
.route("/change-target-branch", post(change_target_branch))
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{collections::HashMap, path::Path};
|
||||
use chrono::{DateTime, Utc};
|
||||
use git2::{
|
||||
BranchType, Delta, DiffFindOptions, DiffOptions, Error as GitError, Reference, Remote,
|
||||
Repository, Sort, build::CheckoutBuilder,
|
||||
Repository, Sort,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
@@ -1077,16 +1077,6 @@ impl GitService {
|
||||
Ok((st.uncommitted_tracked, st.untracked))
|
||||
}
|
||||
|
||||
/// Expose full worktree status details (CLI porcelain parsing)
|
||||
pub fn get_worktree_status(
|
||||
&self,
|
||||
worktree_path: &Path,
|
||||
) -> Result<cli::WorktreeStatus, GitServiceError> {
|
||||
let cli = GitCli::new();
|
||||
cli.get_worktree_status(worktree_path)
|
||||
.map_err(|e| GitServiceError::InvalidRepository(format!("git status failed: {e}")))
|
||||
}
|
||||
|
||||
/// Evaluate whether any action is needed to reset to `target_commit_oid` and
|
||||
/// optionally perform the actions.
|
||||
pub fn reconcile_worktree_to_commit(
|
||||
@@ -1151,33 +1141,6 @@ impl GitService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a local branch at the current HEAD
|
||||
pub fn create_branch(
|
||||
&self,
|
||||
repo_path: &Path,
|
||||
branch_name: &str,
|
||||
) -> Result<(), GitServiceError> {
|
||||
let repo = self.open_repo(repo_path)?;
|
||||
let head_commit = repo.head()?.peel_to_commit()?;
|
||||
repo.branch(branch_name, &head_commit, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checkout a local branch in the given working tree
|
||||
pub fn checkout_branch(
|
||||
&self,
|
||||
repo_path: &Path,
|
||||
branch_name: &str,
|
||||
) -> Result<(), GitServiceError> {
|
||||
let repo = self.open_repo(repo_path)?;
|
||||
let refname = format!("refs/heads/{branch_name}");
|
||||
repo.set_head(&refname)?;
|
||||
let mut co = CheckoutBuilder::new();
|
||||
co.force();
|
||||
repo.checkout_head(Some(&mut co))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a worktree for a branch, optionally creating the branch
|
||||
pub fn add_worktree(
|
||||
&self,
|
||||
@@ -1212,23 +1175,6 @@ impl GitService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set or add a remote URL
|
||||
pub fn set_remote(
|
||||
&self,
|
||||
repo_path: &Path,
|
||||
name: &str,
|
||||
url: &str,
|
||||
) -> Result<(), GitServiceError> {
|
||||
let repo = self.open_repo(repo_path)?;
|
||||
match repo.find_remote(name) {
|
||||
Ok(_) => repo.remote_set_url(name, url)?,
|
||||
Err(_) => {
|
||||
repo.remote(name, url)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_branches(&self, repo_path: &Path) -> Result<Vec<GitBranch>, git2::Error> {
|
||||
let repo = Repository::open(repo_path)?;
|
||||
let current_branch = self.get_current_branch(repo_path).unwrap_or_default();
|
||||
@@ -1588,69 +1534,6 @@ impl GitService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a file from the repository and commit the change
|
||||
pub fn delete_file_and_commit(
|
||||
&self,
|
||||
worktree_path: &Path,
|
||||
file_path: &str,
|
||||
) -> Result<String, GitServiceError> {
|
||||
let repo = Repository::open(worktree_path)?;
|
||||
|
||||
// Get the absolute path to the file within the worktree
|
||||
let file_full_path = worktree_path.join(file_path);
|
||||
|
||||
// Check if file exists and delete it
|
||||
if file_full_path.exists() {
|
||||
std::fs::remove_file(&file_full_path).map_err(|e| {
|
||||
GitServiceError::IoError(std::io::Error::other(format!(
|
||||
"Failed to delete file {file_path}: {e}"
|
||||
)))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Stage the deletion
|
||||
let mut index = repo.index()?;
|
||||
index.remove_path(Path::new(file_path))?;
|
||||
index.write()?;
|
||||
|
||||
// Create a commit for the file deletion
|
||||
let signature = self.signature_with_fallback(&repo)?;
|
||||
let tree_id = index.write_tree()?;
|
||||
let tree = repo.find_tree(tree_id)?;
|
||||
|
||||
// Get the current HEAD commit
|
||||
let head = repo.head()?;
|
||||
let parent_commit = head.peel_to_commit()?;
|
||||
|
||||
let commit_message = format!("Delete file: {file_path}");
|
||||
let commit_id = repo.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
&commit_message,
|
||||
&tree,
|
||||
&[&parent_commit],
|
||||
)?;
|
||||
|
||||
Ok(commit_id.to_string())
|
||||
}
|
||||
|
||||
/// Get the default branch name for the repository
|
||||
pub fn get_default_branch_name(&self, repo_path: &Path) -> Result<String, GitServiceError> {
|
||||
let repo = self.open_repo(repo_path)?;
|
||||
|
||||
match repo.head() {
|
||||
Ok(head_ref) => Ok(head_ref.shorthand().unwrap_or("main").to_string()),
|
||||
Err(e)
|
||||
if e.class() == git2::ErrorClass::Reference
|
||||
&& e.code() == git2::ErrorCode::UnbornBranch =>
|
||||
{
|
||||
Ok("main".to_string()) // Repository has no commits yet
|
||||
}
|
||||
Err(_) => Ok("main".to_string()), // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract GitHub owner and repo name from git repo path
|
||||
pub fn get_github_repo_info(
|
||||
&self,
|
||||
|
||||
@@ -25,9 +25,6 @@ pub enum GitHubServiceError {
|
||||
#[ts(skip)]
|
||||
#[error("Pull request error: {0}")]
|
||||
PullRequest(String),
|
||||
#[ts(skip)]
|
||||
#[error("Branch error: {0}")]
|
||||
Branch(String),
|
||||
#[error("GitHub token is invalid or expired.")]
|
||||
TokenInvalid,
|
||||
#[error("Insufficient permissions")]
|
||||
@@ -153,19 +150,6 @@ pub struct GitHubService {
|
||||
gh_cli: GhCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
pub struct RepositoryInfo {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub owner: String,
|
||||
pub description: Option<String>,
|
||||
pub clone_url: String,
|
||||
pub ssh_url: String,
|
||||
pub default_branch: String,
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
impl GitHubService {
|
||||
/// Create a new GitHub service with authentication
|
||||
pub fn new() -> Result<Self, GitHubServiceError> {
|
||||
@@ -220,26 +204,6 @@ impl GitHubService {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_repository_id(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<i64, GitHubServiceError> {
|
||||
let owner = owner.to_string();
|
||||
let repo = repo.to_string();
|
||||
let cli = self.gh_cli.clone();
|
||||
let owner_for_cli = owner.clone();
|
||||
let repo_for_cli = repo.clone();
|
||||
task::spawn_blocking(move || cli.repo_database_id(&owner_for_cli, &repo_for_cli))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
GitHubServiceError::Repository(format!(
|
||||
"Failed to execute GitHub CLI for repo lookup: {err}"
|
||||
))
|
||||
})?
|
||||
.map_err(GitHubServiceError::from)
|
||||
}
|
||||
|
||||
async fn create_pr_via_cli(
|
||||
&self,
|
||||
repo_info: &GitHubRepoInfo,
|
||||
@@ -350,14 +314,4 @@ impl GitHubService {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
pub async fn list_repositories(
|
||||
&self,
|
||||
_page: u8,
|
||||
) -> Result<Vec<RepositoryInfo>, GitHubServiceError> {
|
||||
Err(GitHubServiceError::Repository(
|
||||
"Listing repositories via GitHub CLI is not supported.".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,8 @@ impl GhCli {
|
||||
|
||||
/// Ensure the GitHub CLI binary is discoverable.
|
||||
fn ensure_available(&self) -> Result<(), GhCliError> {
|
||||
resolve_executable_path_blocking("gh")
|
||||
.ok_or(GhCliError::NotAvailable)
|
||||
.map(|_| ())
|
||||
resolve_executable_path_blocking("gh").ok_or(GhCliError::NotAvailable)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generic helper to execute `gh <args>` and return stdout on success.
|
||||
@@ -128,21 +127,6 @@ impl GhCli {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch repository numeric ID via `gh api`.
|
||||
pub fn repo_database_id(&self, owner: &str, repo: &str) -> Result<i64, GhCliError> {
|
||||
let raw = self.run(["api", &format!("repos/{owner}/{repo}"), "--method", "GET"])?;
|
||||
let value: Value = serde_json::from_str(raw.trim()).map_err(|err| {
|
||||
GhCliError::UnexpectedOutput(format!(
|
||||
"Failed to parse gh api repos response: {err}; raw: {raw}"
|
||||
))
|
||||
})?;
|
||||
value.get("id").and_then(Value::as_i64).ok_or_else(|| {
|
||||
GhCliError::UnexpectedOutput(format!(
|
||||
"gh api repos response missing numeric repository id: {value:#?}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Retrieve details for a single pull request.
|
||||
pub fn view_pr(
|
||||
&self,
|
||||
|
||||
@@ -557,7 +557,8 @@ fn merge_refuses_with_staged_changes_on_base() {
|
||||
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
||||
let s = GitService::new();
|
||||
// ensure main is checked out
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
checkout_branch(&repo, "main");
|
||||
// feature adds change and commits
|
||||
write_file(&worktree_path, "m.txt", "feature\n");
|
||||
let wt_repo = Repository::open(&worktree_path).unwrap();
|
||||
@@ -577,7 +578,8 @@ fn merge_preserves_unstaged_changes_on_base() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
||||
let s = GitService::new();
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
checkout_branch(&repo, "main");
|
||||
// modify unstaged
|
||||
write_file(&repo_path, "common.txt", "local edited\n");
|
||||
// feature modifies a different file
|
||||
@@ -601,8 +603,9 @@ fn update_ref_does_not_destroy_feature_worktree_dirty_state() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
||||
let s = GitService::new();
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
// ensure main is checked out
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
checkout_branch(&repo, "main");
|
||||
// feature makes an initial change and commits
|
||||
write_file(&worktree_path, "f.txt", "feat\n");
|
||||
let wt_repo = Repository::open(&worktree_path).unwrap();
|
||||
@@ -915,17 +918,15 @@ fn merge_refreshes_main_worktree_when_on_base() {
|
||||
let repo_path = td.path().join("repo_refresh");
|
||||
let s = GitService::new();
|
||||
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
||||
{
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
}
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
checkout_branch(&repo, "main");
|
||||
// Baseline file
|
||||
write_file(&repo_path, "file.txt", "base\n");
|
||||
let _ = s.commit(&repo_path, "add base").unwrap();
|
||||
|
||||
// Create feature branch and worktree
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch_from_head(&repo, "feature");
|
||||
let wt = td.path().join("wt_refresh");
|
||||
s.add_worktree(&repo_path, &wt, "feature", false).unwrap();
|
||||
// Modify file in worktree and commit
|
||||
@@ -950,11 +951,9 @@ fn sparse_checkout_respected_in_worktree_diffs_and_commit() {
|
||||
let repo_path = td.path().join("repo_sparse");
|
||||
let s = GitService::new();
|
||||
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
||||
{
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
}
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
checkout_branch(&repo, "main");
|
||||
// baseline content
|
||||
write_file(&repo_path, "included/a.txt", "A\n");
|
||||
write_file(&repo_path, "excluded/b.txt", "B\n");
|
||||
@@ -968,7 +967,7 @@ fn sparse_checkout_respected_in_worktree_diffs_and_commit() {
|
||||
.unwrap();
|
||||
|
||||
// create feature branch and worktree
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch_from_head(&repo, "feature");
|
||||
let wt = td.path().join("wt_sparse");
|
||||
s.add_worktree(&repo_path, &wt, "feature", false).unwrap();
|
||||
|
||||
@@ -1032,16 +1031,14 @@ fn worktree_diff_ignores_commits_where_base_branch_is_ahead() {
|
||||
let repo_path = td.path().join("repo_base_ahead");
|
||||
let s = GitService::new();
|
||||
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
||||
{
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
}
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
checkout_branch(&repo, "main");
|
||||
|
||||
write_file(&repo_path, "shared.txt", "base\n");
|
||||
let _ = s.commit(&repo_path, "add shared").unwrap();
|
||||
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch_from_head(&repo, "feature");
|
||||
let wt = td.path().join("wt_base_ahead");
|
||||
s.add_worktree(&repo_path, &wt, "feature", false).unwrap();
|
||||
|
||||
@@ -1077,11 +1074,9 @@ fn init_repo_only_service(root: &TempDir) -> PathBuf {
|
||||
let repo_path = root.path().join("repo_svc");
|
||||
let s = GitService::new();
|
||||
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
||||
{
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
}
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
checkout_branch(&repo, "main");
|
||||
repo_path
|
||||
}
|
||||
|
||||
@@ -1089,11 +1084,12 @@ fn init_repo_only_service(root: &TempDir) -> PathBuf {
|
||||
fn merge_binary_conflict_does_not_move_ref() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_only_service(&td);
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
let s = GitService::new();
|
||||
// seed
|
||||
let _ = s.commit(&repo_path, "seed").unwrap();
|
||||
// create feature branch and worktree
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch_from_head(&repo, "feature");
|
||||
let worktree_path = td.path().join("wt_bin");
|
||||
s.add_worktree(&repo_path, &worktree_path, "feature", false)
|
||||
.unwrap();
|
||||
@@ -1119,11 +1115,12 @@ fn merge_binary_conflict_does_not_move_ref() {
|
||||
fn merge_rename_vs_modify_conflict_does_not_move_ref() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_only_service(&td);
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
let s = GitService::new();
|
||||
// base file
|
||||
fs::write(repo_path.join("conflict.txt"), b"base\n").unwrap();
|
||||
let _ = s.commit(&repo_path, "base").unwrap();
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch_from_head(&repo, "feature");
|
||||
let worktree_path = td.path().join("wt_ren");
|
||||
s.add_worktree(&repo_path, &worktree_path, "feature", false)
|
||||
.unwrap();
|
||||
@@ -1185,7 +1182,8 @@ fn merge_leaves_no_staged_changes_on_target_branch() {
|
||||
|
||||
// Ensure main repo is on the base branch (triggers CLI merge path)
|
||||
let s = GitService::new();
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
checkout_branch(&repo, "main");
|
||||
|
||||
// Feature branch makes some changes
|
||||
write_file(&worktree_path, "feature_file.txt", "feature content\n");
|
||||
@@ -1236,7 +1234,6 @@ fn worktree_to_worktree_merge_leaves_no_staged_changes() {
|
||||
.expect("init repo");
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
checkout_branch(&repo, "main");
|
||||
|
||||
write_file(&repo_path, "base.txt", "base content\n");
|
||||
commit_all(&repo, "initial commit");
|
||||
@@ -1308,7 +1305,7 @@ fn merge_into_orphaned_branch_uses_libgit2_fallback() {
|
||||
.unwrap();
|
||||
|
||||
// Ensure main repo is on different branch and no worktree has orphaned-feature
|
||||
service.checkout_branch(&repo_path, "main").unwrap();
|
||||
checkout_branch(&repo, "main");
|
||||
|
||||
// Make changes in source worktree
|
||||
write_file(
|
||||
@@ -1369,7 +1366,6 @@ fn merge_base_ahead_of_task_should_error() {
|
||||
.expect("init repo");
|
||||
let repo = Repository::open(&repo_path).unwrap();
|
||||
configure_user(&repo);
|
||||
checkout_branch(&repo, "main");
|
||||
|
||||
// Initial commit on main
|
||||
write_file(&repo_path, "base.txt", "initial content\n");
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use git2::{Repository, build::CheckoutBuilder};
|
||||
use services::services::{
|
||||
git::{DiffTarget, GitCli, GitService},
|
||||
github::{GitHubRepoInfo, GitHubServiceError},
|
||||
@@ -60,10 +61,24 @@ fn init_repo_main(root: &TempDir) -> PathBuf {
|
||||
let s = GitService::new();
|
||||
s.initialize_repo_with_main_branch(&path).unwrap();
|
||||
configure_user(&path, "Test User", "test@example.com");
|
||||
s.checkout_branch(&path, "main").unwrap();
|
||||
checkout_branch(&path, "main");
|
||||
path
|
||||
}
|
||||
|
||||
fn checkout_branch(repo_path: &Path, name: &str) {
|
||||
let repo = Repository::open(repo_path).unwrap();
|
||||
repo.set_head(&format!("refs/heads/{name}")).unwrap();
|
||||
let mut co = CheckoutBuilder::new();
|
||||
co.force();
|
||||
repo.checkout_head(Some(&mut co)).unwrap();
|
||||
}
|
||||
|
||||
fn create_branch(repo_path: &Path, name: &str) {
|
||||
let repo = Repository::open(repo_path).unwrap();
|
||||
let head = repo.head().unwrap().peel_to_commit().unwrap();
|
||||
let _ = repo.branch(name, &head, true).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_empty_message_behaviour() {
|
||||
let td = TempDir::new().unwrap();
|
||||
@@ -148,35 +163,6 @@ fn staged_but_uncommitted_changes_is_dirty() {
|
||||
assert!(!s.is_worktree_clean(&repo_path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_nonexistent_file_creates_noop_commit() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_main(&td);
|
||||
// baseline commit first so we have HEAD
|
||||
write_file(&repo_path, "seed.txt", "s\n");
|
||||
let s = GitService::new();
|
||||
let _ = s.commit(&repo_path, "seed").unwrap();
|
||||
let before = s.get_head_info(&repo_path).unwrap().oid;
|
||||
let res = s.delete_file_and_commit(&repo_path, "nope.txt").unwrap();
|
||||
let after = s.get_head_info(&repo_path).unwrap().oid;
|
||||
assert_ne!(before, after);
|
||||
assert_eq!(after, res);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_directory_path_errors() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_main(&td);
|
||||
// create and commit a file so repo has history
|
||||
write_file(&repo_path, "dir/file.txt", "z\n");
|
||||
let s = GitService::new();
|
||||
let _ = s.commit(&repo_path, "add file").unwrap();
|
||||
// directory path should cause an error
|
||||
let s = GitService::new();
|
||||
let res = s.delete_file_and_commit(&repo_path, "dir");
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_clean_detects_staged_deleted_and_renamed() {
|
||||
let td = TempDir::new().unwrap();
|
||||
@@ -206,8 +192,8 @@ fn diff_added_binary_file_has_no_content() {
|
||||
let s = GitService::new();
|
||||
let _ = s.commit(&repo_path, "base").unwrap();
|
||||
// branch with binary file
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
s.checkout_branch(&repo_path, "feature").unwrap();
|
||||
create_branch(&repo_path, "feature");
|
||||
checkout_branch(&repo_path, "feature");
|
||||
// write binary with null byte
|
||||
let mut f = fs::File::create(repo_path.join("bin.dat")).unwrap();
|
||||
f.write_all(&[0u8, 1, 2, 3]).unwrap();
|
||||
@@ -236,11 +222,7 @@ fn initialize_and_default_branch_and_head_info() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_main(&td);
|
||||
|
||||
// Default branch should be main
|
||||
let s = GitService::new();
|
||||
let def = s.get_default_branch_name(&repo_path).unwrap();
|
||||
assert_eq!(def, "main");
|
||||
|
||||
// Head info branch should be main
|
||||
let head = s.get_head_info(&repo_path).unwrap();
|
||||
assert_eq!(head.branch, "main");
|
||||
@@ -306,14 +288,14 @@ fn branch_status_ahead_and_behind() {
|
||||
let _ = s.commit(&repo_path, "base").unwrap();
|
||||
|
||||
// create feature from main
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch(&repo_path, "feature");
|
||||
// advance feature by 1
|
||||
s.checkout_branch(&repo_path, "feature").unwrap();
|
||||
checkout_branch(&repo_path, "feature");
|
||||
write_file(&repo_path, "feature.txt", "f1\n");
|
||||
let _ = s.commit(&repo_path, "f1").unwrap();
|
||||
|
||||
// advance main by 1
|
||||
s.checkout_branch(&repo_path, "main").unwrap();
|
||||
checkout_branch(&repo_path, "main");
|
||||
write_file(&repo_path, "main.txt", "m1\n");
|
||||
let _ = s.commit(&repo_path, "m1").unwrap();
|
||||
|
||||
@@ -322,7 +304,7 @@ fn branch_status_ahead_and_behind() {
|
||||
assert_eq!((ahead, behind), (1, 1));
|
||||
|
||||
// advance feature by one more (ahead 2, behind 1)
|
||||
s.checkout_branch(&repo_path, "feature").unwrap();
|
||||
checkout_branch(&repo_path, "feature");
|
||||
write_file(&repo_path, "feature2.txt", "f2\n");
|
||||
let _ = s.commit(&repo_path, "f2").unwrap();
|
||||
let (ahead2, behind2) = s.get_branch_status(&repo_path, "feature", "main").unwrap();
|
||||
@@ -333,8 +315,7 @@ fn branch_status_ahead_and_behind() {
|
||||
fn get_all_branches_lists_current_and_others() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_main(&td);
|
||||
let s = GitService::new();
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch(&repo_path, "feature");
|
||||
|
||||
let s = GitService::new();
|
||||
let branches = s.get_all_branches(&repo_path).unwrap();
|
||||
@@ -346,36 +327,6 @@ fn get_all_branches_lists_current_and_others() {
|
||||
assert!(main_entry.is_current);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_file_and_commit_creates_new_commit() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_main(&td);
|
||||
write_file(&repo_path, "to_delete.txt", "bye\n");
|
||||
let s = GitService::new();
|
||||
let _ = s.commit(&repo_path, "add to_delete").unwrap();
|
||||
let before = s.get_head_info(&repo_path).unwrap().oid;
|
||||
|
||||
let new_commit = s
|
||||
.delete_file_and_commit(&repo_path, "to_delete.txt")
|
||||
.unwrap();
|
||||
let after = s.get_head_info(&repo_path).unwrap().oid;
|
||||
assert_ne!(before, after);
|
||||
assert_eq!(after, new_commit);
|
||||
assert!(!repo_path.join("to_delete.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_github_repo_info_parses_origin() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_main(&td);
|
||||
let s = GitService::new();
|
||||
s.set_remote(&repo_path, "origin", "https://github.com/foo/bar.git")
|
||||
.unwrap();
|
||||
let info = s.get_github_repo_info(&repo_path).unwrap();
|
||||
assert_eq!(info.owner, "foo");
|
||||
assert_eq!(info.repo_name, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_branch_diffs_between_branches() {
|
||||
let td = TempDir::new().unwrap();
|
||||
@@ -386,8 +337,8 @@ fn get_branch_diffs_between_branches() {
|
||||
let _ = s.commit(&repo_path, "add a").unwrap();
|
||||
|
||||
// create branch and add new file
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
s.checkout_branch(&repo_path, "feature").unwrap();
|
||||
create_branch(&repo_path, "feature");
|
||||
checkout_branch(&repo_path, "feature");
|
||||
write_file(&repo_path, "b.txt", "b\n");
|
||||
let _ = s.commit(&repo_path, "add b").unwrap();
|
||||
|
||||
@@ -418,7 +369,7 @@ fn worktree_diff_respects_path_filter() {
|
||||
let _ = s.commit(&repo_path, "baseline").unwrap();
|
||||
|
||||
// create feature and work in place (worktree is repo_path)
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch(&repo_path, "feature");
|
||||
|
||||
// modify files without committing
|
||||
write_file(&repo_path, "src/only.txt", "only\n");
|
||||
@@ -466,7 +417,7 @@ fn create_unicode_branch_and_list() {
|
||||
let _ = s.commit(&repo_path, "base");
|
||||
// unicode/slash branch name (valid ref)
|
||||
let bname = "feature/ünicode";
|
||||
s.create_branch(&repo_path, bname).unwrap();
|
||||
create_branch(&repo_path, bname);
|
||||
let names: Vec<_> = s
|
||||
.get_all_branches(&repo_path)
|
||||
.unwrap()
|
||||
@@ -487,7 +438,7 @@ fn worktree_diff_permission_only_change() {
|
||||
write_file(&repo_path, "p.sh", "echo hi\n");
|
||||
let _ = s.commit(&repo_path, "add p.sh").unwrap();
|
||||
// create a feature branch baseline at HEAD
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch(&repo_path, "feature");
|
||||
|
||||
// change only the permission (chmod +x)
|
||||
let mut perms = std::fs::metadata(repo_path.join("p.sh"))
|
||||
@@ -515,67 +466,6 @@ fn worktree_diff_permission_only_change() {
|
||||
assert_eq!(d.old_content, d.new_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_uncommitted_changes_succeeds() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_main(&td);
|
||||
let s = GitService::new();
|
||||
// baseline file and commit
|
||||
write_file(&repo_path, "d.txt", "v1\n");
|
||||
let _ = s.commit(&repo_path, "add d").unwrap();
|
||||
let before = s.get_head_info(&repo_path).unwrap().oid;
|
||||
// uncommitted change
|
||||
write_file(&repo_path, "d.txt", "v2\n");
|
||||
// delete and commit
|
||||
let new_sha = s.delete_file_and_commit(&repo_path, "d.txt").unwrap();
|
||||
assert_eq!(s.get_head_info(&repo_path).unwrap().oid, new_sha);
|
||||
assert!(!repo_path.join("d.txt").exists());
|
||||
assert_ne!(before, new_sha);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn delete_symlink_and_commit() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = init_repo_main(&td);
|
||||
let s = GitService::new();
|
||||
// Create target and symlink, commit
|
||||
write_file(&repo_path, "target.txt", "t\n");
|
||||
let _ = s.commit(&repo_path, "add target").unwrap();
|
||||
symlink(repo_path.join("target.txt"), repo_path.join("link.txt")).unwrap();
|
||||
let _ = s.commit(&repo_path, "add symlink").unwrap();
|
||||
let before = s.get_head_info(&repo_path).unwrap().oid;
|
||||
// Delete symlink
|
||||
let new_sha = s.delete_file_and_commit(&repo_path, "link.txt").unwrap();
|
||||
assert_eq!(s.get_head_info(&repo_path).unwrap().oid, new_sha);
|
||||
assert!(!repo_path.join("link.txt").exists());
|
||||
assert_ne!(before, new_sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_file_commit_has_author_without_user() {
|
||||
// Verify libgit2 path uses fallback author when no config exists
|
||||
let td = TempDir::new().unwrap();
|
||||
let repo_path = td.path().join("repo_fallback_delete");
|
||||
let s = GitService::new();
|
||||
// No configure_user call; initial commit uses fallback signature too
|
||||
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
||||
|
||||
// Create then delete an untracked file via service
|
||||
write_file(&repo_path, "q.txt", "temp\n");
|
||||
let sha = s.delete_file_and_commit(&repo_path, "q.txt").unwrap();
|
||||
|
||||
// Author should be present: either global identity or fallback
|
||||
let (name, email) = get_commit_author(&repo_path, &sha);
|
||||
if has_global_git_identity() {
|
||||
assert!(name.is_some() && email.is_some());
|
||||
} else {
|
||||
assert_eq!(name.as_deref(), Some("Vibe Kanban"));
|
||||
assert_eq!(email.as_deref(), Some("noreply@vibekanban.com"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_repo_info_parses_https_and_ssh_urls() {
|
||||
let info = GitHubRepoInfo::from_remote_url("https://github.com/owner/repo.git").unwrap();
|
||||
@@ -611,7 +501,7 @@ fn squash_merge_libgit2_sets_author_without_user() {
|
||||
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
||||
|
||||
// Create feature branch and worktree
|
||||
s.create_branch(&repo_path, "feature").unwrap();
|
||||
create_branch(&repo_path, "feature");
|
||||
s.add_worktree(&repo_path, &worktree_path, "feature", false)
|
||||
.unwrap();
|
||||
|
||||
@@ -635,8 +525,8 @@ fn squash_merge_libgit2_sets_author_without_user() {
|
||||
}
|
||||
|
||||
// Ensure main repo is NOT on base branch so merge_changes takes libgit2 path
|
||||
s.create_branch(&repo_path, "dev").unwrap();
|
||||
s.checkout_branch(&repo_path, "dev").unwrap();
|
||||
create_branch(&repo_path, "dev");
|
||||
checkout_branch(&repo_path, "dev");
|
||||
|
||||
// Merge feature -> main (libgit2 squash)
|
||||
let merge_sha = s
|
||||
|
||||
@@ -246,11 +246,10 @@ export default function DiffCard({
|
||||
if (!selectedAttempt?.id) return;
|
||||
try {
|
||||
const openPath = newName || oldName;
|
||||
const response = await attemptsApi.openEditor(
|
||||
selectedAttempt.id,
|
||||
undefined,
|
||||
openPath || undefined
|
||||
);
|
||||
const response = await attemptsApi.openEditor(selectedAttempt.id, {
|
||||
editor_type: null,
|
||||
file_path: openPath ?? null,
|
||||
});
|
||||
|
||||
// If a URL is returned, open it in a new window/tab
|
||||
if (response.url) {
|
||||
|
||||
@@ -19,11 +19,10 @@ export function useOpenInEditor(
|
||||
const { editorType, filePath } = options ?? {};
|
||||
|
||||
try {
|
||||
const response = await attemptsApi.openEditor(
|
||||
attemptId,
|
||||
editorType,
|
||||
filePath
|
||||
);
|
||||
const response = await attemptsApi.openEditor(attemptId, {
|
||||
editor_type: editorType ?? null,
|
||||
file_path: filePath ?? null,
|
||||
});
|
||||
|
||||
// If a URL is returned, open it in a new window/tab
|
||||
if (response.url) {
|
||||
|
||||
@@ -12,7 +12,10 @@ export function useOpenProjectInEditor(
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
const response = await projectsApi.openEditor(project.id, editorType);
|
||||
const response = await projectsApi.openEditor(project.id, {
|
||||
editor_type: editorType ?? null,
|
||||
file_path: null,
|
||||
});
|
||||
|
||||
// If a URL is returned, open it in a new window/tab
|
||||
if (response.url) {
|
||||
|
||||
@@ -14,12 +14,10 @@ import {
|
||||
CreateTag,
|
||||
DirectoryListResponse,
|
||||
DirectoryEntry,
|
||||
EditorType,
|
||||
ExecutionProcess,
|
||||
GitBranch,
|
||||
Project,
|
||||
CreateProject,
|
||||
RepositoryInfo,
|
||||
SearchResult,
|
||||
ShareTaskResponse,
|
||||
Task,
|
||||
@@ -68,10 +66,12 @@ import {
|
||||
Invitation,
|
||||
RemoteProject,
|
||||
ListInvitationsResponse,
|
||||
CommitCompareResult,
|
||||
OpenEditorResponse,
|
||||
OpenEditorRequest,
|
||||
} from 'shared/types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { RepositoryInfo } from 'shared/types';
|
||||
export type {
|
||||
UpdateFollowUpDraftRequest,
|
||||
UpdateRetryFollowUpDraftRequest,
|
||||
@@ -106,16 +106,6 @@ const makeRequest = async (url: string, options: RequestInit = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
export interface FollowUpResponse {
|
||||
message: string;
|
||||
actual_attempt_id: string;
|
||||
created_new_attempt: boolean;
|
||||
}
|
||||
|
||||
export interface OpenEditorResponse {
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export type Ok<T> = { success: true; data: T };
|
||||
export type Err<E> = { success: false; error: E | undefined; message?: string };
|
||||
|
||||
@@ -272,16 +262,11 @@ export const projectsApi = {
|
||||
|
||||
openEditor: async (
|
||||
id: string,
|
||||
editorType?: EditorType
|
||||
data: OpenEditorRequest
|
||||
): Promise<OpenEditorResponse> => {
|
||||
const requestBody: { editor_type?: EditorType } = {};
|
||||
if (editorType) requestBody.editor_type = editorType;
|
||||
|
||||
const response = await makeRequest(`/api/projects/${id}/open-editor`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(
|
||||
Object.keys(requestBody).length > 0 ? requestBody : null
|
||||
),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleApiResponse<OpenEditorResponse>(response);
|
||||
},
|
||||
@@ -340,11 +325,6 @@ export const projectsApi = {
|
||||
|
||||
// Task Management APIs
|
||||
export const tasksApi = {
|
||||
getAll: async (projectId: string): Promise<TaskWithAttemptStatus[]> => {
|
||||
const response = await makeRequest(`/api/tasks?project_id=${projectId}`);
|
||||
return handleApiResponse<TaskWithAttemptStatus[]>(response);
|
||||
},
|
||||
|
||||
getById: async (taskId: string): Promise<Task> => {
|
||||
const response = await makeRequest(`/api/tasks/${taskId}`);
|
||||
return handleApiResponse<Task>(response);
|
||||
@@ -452,26 +432,6 @@ export const attemptsApi = {
|
||||
return handleApiResponse<void>(response);
|
||||
},
|
||||
|
||||
replaceProcess: async (
|
||||
attemptId: string,
|
||||
data: {
|
||||
process_id: string;
|
||||
prompt: string;
|
||||
variant?: string | null;
|
||||
force_when_dirty?: boolean;
|
||||
perform_git_reset?: boolean;
|
||||
}
|
||||
): Promise<unknown> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/replace-process`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
return handleApiResponse(response);
|
||||
},
|
||||
|
||||
followUp: async (
|
||||
attemptId: string,
|
||||
data: CreateFollowUpAttempt
|
||||
@@ -557,37 +517,15 @@ export const attemptsApi = {
|
||||
return handleApiResponse<DraftResponse>(response);
|
||||
},
|
||||
|
||||
deleteFile: async (
|
||||
attemptId: string,
|
||||
fileToDelete: string
|
||||
): Promise<void> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/delete-file?file_path=${encodeURIComponent(
|
||||
fileToDelete
|
||||
)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return handleApiResponse<void>(response);
|
||||
},
|
||||
|
||||
openEditor: async (
|
||||
attemptId: string,
|
||||
editorType?: EditorType,
|
||||
filePath?: string
|
||||
data: OpenEditorRequest
|
||||
): Promise<OpenEditorResponse> => {
|
||||
const requestBody: { editor_type?: EditorType; file_path?: string } = {};
|
||||
if (editorType) requestBody.editor_type = editorType;
|
||||
if (filePath) requestBody.file_path = filePath;
|
||||
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/open-editor`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(
|
||||
Object.keys(requestBody).length > 0 ? requestBody : null
|
||||
),
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
return handleApiResponse<OpenEditorResponse>(response);
|
||||
@@ -717,13 +655,7 @@ export const commitsApi = {
|
||||
compareToHead: async (
|
||||
attemptId: string,
|
||||
sha: string
|
||||
): Promise<{
|
||||
head_oid: string;
|
||||
target_oid: string;
|
||||
ahead_from_head: number;
|
||||
behind_from_head: number;
|
||||
is_linear: boolean;
|
||||
}> => {
|
||||
): Promise<CommitCompareResult> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/commit-compare?sha=${encodeURIComponent(
|
||||
sha
|
||||
@@ -735,15 +667,6 @@ export const commitsApi = {
|
||||
|
||||
// Execution Process APIs
|
||||
export const executionProcessesApi = {
|
||||
getExecutionProcesses: async (
|
||||
attemptId: string
|
||||
): Promise<ExecutionProcess[]> => {
|
||||
const response = await makeRequest(
|
||||
`/api/execution-processes?task_attempt_id=${attemptId}`
|
||||
);
|
||||
return handleApiResponse<ExecutionProcess[]>(response);
|
||||
},
|
||||
|
||||
getDetails: async (processId: string): Promise<ExecutionProcess> => {
|
||||
const response = await makeRequest(`/api/execution-processes/${processId}`);
|
||||
return handleApiResponse<ExecutionProcess>(response);
|
||||
@@ -794,14 +717,6 @@ export const configApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// GitHub APIs (only available in cloud mode)
|
||||
export const githubApi = {
|
||||
listRepositories: async (page: number = 1): Promise<RepositoryInfo[]> => {
|
||||
const response = await makeRequest(`/api/github/repositories?page=${page}`);
|
||||
return handleApiResponse<RepositoryInfo[]>(response);
|
||||
},
|
||||
};
|
||||
|
||||
// Task Tags APIs (all tags are global)
|
||||
export const tagsApi = {
|
||||
list: async (params?: TagSearchParams): Promise<Tag[]> => {
|
||||
@@ -812,11 +727,6 @@ export const tagsApi = {
|
||||
return handleApiResponse<Tag[]>(response);
|
||||
},
|
||||
|
||||
get: async (tagId: string): Promise<Tag> => {
|
||||
const response = await makeRequest(`/api/tags/${tagId}`);
|
||||
return handleApiResponse<Tag>(response);
|
||||
},
|
||||
|
||||
create: async (data: CreateTag): Promise<Tag> => {
|
||||
const response = await makeRequest('/api/tags', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -152,6 +152,12 @@ export type RenameBranchRequest = { new_branch_name: string, };
|
||||
|
||||
export type RenameBranchResponse = { branch: string, };
|
||||
|
||||
export type CommitCompareResult = { head_oid: string, target_oid: string, ahead_from_head: number, behind_from_head: number, is_linear: boolean, };
|
||||
|
||||
export type OpenEditorRequest = { editor_type: string | null, file_path: string | null, };
|
||||
|
||||
export type OpenEditorResponse = { url: string | null, };
|
||||
|
||||
export type AssignSharedTaskRequest = { new_assignee_user_id: string | null, version: bigint | null, };
|
||||
|
||||
export type AssignSharedTaskResponse = { shared_task: SharedTask, };
|
||||
@@ -198,8 +204,6 @@ additions: number | null, deletions: number | null, };
|
||||
|
||||
export type DiffChangeKind = "added" | "deleted" | "modified" | "renamed" | "copied" | "permissionChange";
|
||||
|
||||
export type RepositoryInfo = { id: bigint, name: string, full_name: string, owner: string, description: string | null, clone_url: string, ssh_url: string, default_branch: string, private: boolean, };
|
||||
|
||||
export type CommandBuilder = {
|
||||
/**
|
||||
* Base executable command (e.g., "npx -y @anthropic-ai/claude-code@latest")
|
||||
@@ -284,28 +288,6 @@ export type RebaseTaskAttemptRequest = { old_base_branch: string | null, new_bas
|
||||
|
||||
export type GitOperationError = { "type": "merge_conflicts", message: string, op: ConflictOp, } | { "type": "rebase_in_progress" };
|
||||
|
||||
export type ReplaceProcessRequest = {
|
||||
/**
|
||||
* Process to replace (delete this and later ones)
|
||||
*/
|
||||
process_id: string,
|
||||
/**
|
||||
* New prompt to use for the replacement follow-up
|
||||
*/
|
||||
prompt: string,
|
||||
/**
|
||||
* Optional variant override
|
||||
*/
|
||||
variant: string | null,
|
||||
/**
|
||||
* If true, allow resetting Git even when uncommitted changes exist
|
||||
*/
|
||||
force_when_dirty: boolean | null,
|
||||
/**
|
||||
* If false, skip performing the Git reset step (history drop still applies)
|
||||
*/
|
||||
perform_git_reset: boolean | null, };
|
||||
|
||||
export type CommitInfo = { sha: string, subject: string, };
|
||||
|
||||
export type BranchStatus = { commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, target_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array<Merge>,
|
||||
|
||||
Reference in New Issue
Block a user