Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions crates/forge_api/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,12 @@ pub trait API: Sync + Send {
&self,
data_parameters: DataGenerationParameters,
) -> Result<BoxStream<'static, Result<serde_json::Value, anyhow::Error>>>;

/// Returns all tracked background processes with their alive status.
async fn list_background_processes(
&self,
) -> Result<Vec<(forge_domain::BackgroundProcess, bool)>>;

/// Kills a background process by PID and optionally deletes its log file.
async fn kill_background_process(&self, pid: u32, delete_log: bool) -> Result<()>;
}
20 changes: 18 additions & 2 deletions crates/forge_api/src/forge_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use forge_app::{
AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra,
CommandLoaderService, ConversationService, DataGenerationApp, EnvironmentInfra,
EnvironmentService, FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager,
McpService, ProviderAuthService, ProviderService, Services, User, UserUsage, Walker,
WorkspaceService,
McpService, ProviderAuthService, ProviderService, Services, ShellService, User, UserUsage,
Walker, WorkspaceService,
};
use forge_domain::{Agent, ConsoleWriter, InitAuth, LoginInfo, *};
use forge_infra::ForgeInfra;
Expand Down Expand Up @@ -415,6 +415,22 @@ impl<
app.execute(data_parameters).await
}

async fn list_background_processes(
&self,
) -> Result<Vec<(forge_domain::BackgroundProcess, bool)>> {
self.services
.shell_service()
.list_background_processes()
.await
}

async fn kill_background_process(&self, pid: u32, delete_log: bool) -> Result<()> {
self.services
.shell_service()
.kill_background_process(pid, delete_log)
.await
}

async fn get_default_provider(&self) -> Result<Provider<Url>> {
let provider_id = self.services.get_default_provider().await?;
self.services.get_provider(provider_id).await
Expand Down
13 changes: 8 additions & 5 deletions crates/forge_app/src/fmt/fmt_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,14 @@ impl FormatContent for ToolCatalog {
let display_path = display_path_for(&input.path);
Some(TitleFormat::debug("Undo").sub_title(display_path).into())
}
ToolCatalog::Shell(input) => Some(
TitleFormat::debug(format!("Execute [{}]", env.shell))
.sub_title(&input.command)
.into(),
),
ToolCatalog::Shell(input) => {
let label = if input.background {
format!("Spawned [{}]", env.shell)
} else {
format!("Execute [{}]", env.shell)
};
Some(TitleFormat::debug(label).sub_title(&input.command).into())
}
ToolCatalog::Fetch(input) => {
Some(TitleFormat::debug("GET").sub_title(&input.url).into())
}
Expand Down
14 changes: 7 additions & 7 deletions crates/forge_app/src/fmt/fmt_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ mod tests {
use crate::operation::ToolOperation;
use crate::{
Content, FsRemoveOutput, FsUndoOutput, FsWriteOutput, HttpResponse, Match, MatchResult,
PatchOutput, ReadOutput, ResponseContext, SearchResult, ShellOutput,
PatchOutput, ReadOutput, ResponseContext, SearchResult, ShellOutput, ShellOutputKind,
};

// ContentFormat methods are now implemented in ChatResponseContent
Expand Down Expand Up @@ -421,12 +421,12 @@ mod tests {
fn test_shell_success() {
let fixture = ToolOperation::Shell {
output: ShellOutput {
output: forge_domain::CommandOutput {
kind: ShellOutputKind::Foreground(forge_domain::CommandOutput {
command: "ls -la".to_string(),
stdout: "file1.txt\nfile2.txt".to_string(),
stderr: "".to_string(),
exit_code: Some(0),
},
}),
shell: "/bin/bash".to_string(),
description: None,
},
Expand All @@ -443,12 +443,12 @@ mod tests {
fn test_shell_success_with_stderr() {
let fixture = ToolOperation::Shell {
output: ShellOutput {
output: forge_domain::CommandOutput {
kind: ShellOutputKind::Foreground(forge_domain::CommandOutput {
command: "command_with_warnings".to_string(),
stdout: "output line".to_string(),
stderr: "warning line".to_string(),
exit_code: Some(0),
},
}),
shell: "/bin/bash".to_string(),
description: None,
},
Expand All @@ -465,12 +465,12 @@ mod tests {
fn test_shell_failure() {
let fixture = ToolOperation::Shell {
output: ShellOutput {
output: forge_domain::CommandOutput {
kind: ShellOutputKind::Foreground(forge_domain::CommandOutput {
command: "failing_command".to_string(),
stdout: "".to_string(),
stderr: "Error: command not found".to_string(),
exit_code: Some(127),
},
}),
shell: "/bin/bash".to_string(),
description: None,
},
Expand Down
62 changes: 46 additions & 16 deletions crates/forge_app/src/git_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,24 +162,25 @@ where

let commit_result = self
.services
.execute(commit_command, cwd, false, true, None, None)
.execute(commit_command, cwd, false, true, false, None, None)
.await
.context("Failed to commit changes")?;

if !commit_result.output.success() {
anyhow::bail!("Git commit failed: {}", commit_result.output.stderr);
let output = commit_result
.foreground()
.expect("git commit runs in foreground");

if !output.success() {
anyhow::bail!("Git commit failed: {}", output.stderr);
}

// Combine stdout and stderr for logging
let git_output = if commit_result.output.stdout.is_empty() {
commit_result.output.stderr.clone()
} else if commit_result.output.stderr.is_empty() {
commit_result.output.stdout.clone()
let git_output = if output.stdout.is_empty() {
output.stderr.clone()
} else if output.stderr.is_empty() {
output.stdout.clone()
} else {
format!(
"{}\n{}",
commit_result.output.stdout, commit_result.output.stderr
)
format!("{}\n{}", output.stdout, output.stderr)
};

Ok(CommitResult { message, committed: true, has_staged_files, git_output })
Expand Down Expand Up @@ -230,6 +231,7 @@ where
cwd.to_path_buf(),
false,
true,
false,
None,
None,
),
Expand All @@ -238,6 +240,7 @@ where
cwd.to_path_buf(),
false,
true,
false,
None,
None,
),
Expand All @@ -246,7 +249,18 @@ where
let recent_commits = recent_commits.context("Failed to get recent commits")?;
let branch_name = branch_name.context("Failed to get branch name")?;

Ok((recent_commits.output.stdout, branch_name.output.stdout))
Ok((
recent_commits
.foreground()
.expect("git log runs in foreground")
.stdout
.clone(),
branch_name
.foreground()
.expect("git rev-parse runs in foreground")
.stdout
.clone(),
))
}

/// Fetches diff from git (staged or unstaged)
Expand All @@ -257,6 +271,7 @@ where
cwd.to_path_buf(),
false,
true,
false,
None,
None,
),
Expand All @@ -265,6 +280,7 @@ where
cwd.to_path_buf(),
false,
true,
false,
None,
None,
)
Expand All @@ -274,17 +290,31 @@ where
let unstaged_diff = unstaged_diff.context("Failed to get unstaged changes")?;

// Use staged changes if available, otherwise fall back to unstaged changes
let has_staged_files = !staged_diff.output.stdout.trim().is_empty();
let has_staged_files = !staged_diff
.foreground()
.expect("git diff runs in foreground")
.stdout
.trim()
.is_empty();
let diff_output = if has_staged_files {
staged_diff
} else if !unstaged_diff.output.stdout.trim().is_empty() {
} else if !unstaged_diff
.foreground()
.expect("git diff runs in foreground")
.stdout
.trim()
.is_empty()
{
unstaged_diff
} else {
return Err(GitAppError::NoChangesToCommit.into());
};

let size = diff_output.output.stdout.len();
Ok((diff_output.output.stdout, size, has_staged_files))
let fg = diff_output
.foreground()
.expect("git diff runs in foreground");
let size = fg.stdout.len();
Ok((fg.stdout.clone(), size, has_staged_files))
}

/// Resolves the provider and model from the active agent's configuration.
Expand Down
16 changes: 14 additions & 2 deletions crates/forge_app/src/infra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use std::path::{Path, PathBuf};
use anyhow::Result;
use bytes::Bytes;
use forge_domain::{
AuthCodeParams, CommandOutput, Environment, FileInfo, McpServerConfig, OAuthConfig,
OAuthTokenResponse, ToolDefinition, ToolName, ToolOutput,
AuthCodeParams, BackgroundCommandOutput, CommandOutput, Environment, FileInfo, McpServerConfig,
OAuthConfig, OAuthTokenResponse, ToolDefinition, ToolName, ToolOutput,
};
use reqwest::Response;
use reqwest::header::HeaderMap;
Expand Down Expand Up @@ -143,6 +143,18 @@ pub trait CommandInfra: Send + Sync {
working_dir: PathBuf,
env_vars: Option<Vec<String>>,
) -> anyhow::Result<std::process::ExitStatus>;

/// Spawns a command as a detached background process.
///
/// The process's stdout/stderr are redirected to a temporary log file.
/// Returns a `BackgroundCommandOutput` with the PID, log path, and the
/// temp-file handle that owns the log file on disk.
async fn execute_command_background(
&self,
command: String,
working_dir: PathBuf,
env_vars: Option<Vec<String>>,
) -> anyhow::Result<BackgroundCommandOutput>;
}

#[async_trait::async_trait]
Expand Down
Loading
Loading