Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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 @@ -227,4 +227,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<()>;
}
19 changes: 18 additions & 1 deletion crates/forge_api/src/forge_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use forge_app::{
AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra,
CommandLoaderService, ConversationService, DataGenerationApp, EnvironmentInfra,
FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager, McpService,
ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, WorkspaceService,
ProviderAuthService, ProviderService, Services, ShellService, User, UserUsage, Walker,
WorkspaceService,
};
use forge_domain::{Agent, ConsoleWriter, *};
use forge_infra::ForgeInfra;
Expand Down Expand Up @@ -379,6 +380,22 @@ impl<A: Services, F: CommandInfra + EnvironmentInfra + SkillRepository + GrpcInf
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, ConfigOperation, Environment, FileInfo, McpServerConfig,
OAuthConfig, OAuthTokenResponse, ToolDefinition, ToolName, ToolOutput,
AuthCodeParams, BackgroundCommandOutput, CommandOutput, ConfigOperation, Environment, FileInfo,
McpServerConfig, OAuthConfig, OAuthTokenResponse, ToolDefinition, ToolName, ToolOutput,
};
use reqwest::Response;
use reqwest::header::HeaderMap;
Expand Down Expand Up @@ -148,6 +148,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