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:
Alex Netsch
2025-11-19 12:53:56 +00:00
committed by GitHub
parent 20b8de95d2
commit 85690d6ac9
14 changed files with 98 additions and 714 deletions

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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()

View File

@@ -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))

View File

@@ -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,

View File

@@ -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(),
))
}
}

View File

@@ -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,

View File

@@ -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();
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();
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();
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();
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");

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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>,