use std::collections::hash_map::DefaultHasher;
use std::future::Future;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use askama::Template;
use tokio::sync::mpsc;
use super::{SessionTaskService, session_branch};
use crate::app::assist::{
AssistContext, AssistPolicy, FailureTracker, append_assist_header, format_detail_lines,
run_agent_assist,
};
use crate::app::session::{Clock, SessionError};
use crate::app::{AppEvent, AppServices, ProjectManager, SessionManager};
use crate::domain::agent::{AgentModel, ReasoningLevel};
use crate::domain::session::Status;
use crate::infra::agent;
use crate::infra::agent::protocol::AgentResponseSummary;
use crate::infra::db::Database;
use crate::infra::fs::{self as fs, FsClient};
use crate::infra::git::{self as git, GitClient};
const REBASE_ASSIST_POLICY: AssistPolicy = AssistPolicy {
max_attempts: 3,
max_identical_failure_streak: 3,
};
pub(crate) struct SessionMergeService;
#[derive(Template)]
#[template(path = "rebase_assist_prompt.md", escape = "none")]
struct RebaseAssistPromptTemplate<'a> {
base_branch: &'a str,
conflicted_files: &'a str,
}
type SyncAssistFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
struct MergeTaskInput {
app_event_tx: mpsc::UnboundedSender<AppEvent>,
base_branch: String,
child_pid: Arc<Mutex<Option<u32>>>,
clock: Arc<dyn Clock>,
db: Database,
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
id: String,
output: Arc<Mutex<String>>,
repo_root: PathBuf,
session_model: AgentModel,
source_branch: String,
status: Arc<Mutex<Status>>,
}
#[derive(Clone)]
struct RebaseAssistInput {
app_event_tx: mpsc::UnboundedSender<AppEvent>,
base_branch: String,
child_pid: Arc<Mutex<Option<u32>>>,
db: Database,
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
id: String,
output: Arc<Mutex<String>>,
session_model: AgentModel,
}
struct RebaseTaskInput {
app_event_tx: mpsc::UnboundedSender<AppEvent>,
base_branch: String,
child_pid: Arc<Mutex<Option<u32>>>,
clock: Arc<dyn Clock>,
db: Database,
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
id: String,
output: Arc<Mutex<String>>,
session_model: AgentModel,
status: Arc<Mutex<Status>>,
}
struct SyncRebaseAssistInput {
base_branch: String,
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
session_model: AgentModel,
sync_assist_client: Arc<dyn SyncAssistClient>,
}
enum RebaseAssistLoopInput {
Session(RebaseAssistInput),
Project(SyncRebaseAssistInput),
}
impl RebaseAssistLoopInput {
fn folder(&self) -> &Path {
match self {
Self::Session(input) => &input.folder,
Self::Project(input) => &input.folder,
}
}
fn fs_client(&self) -> &dyn FsClient {
match self {
Self::Session(input) => input.fs_client.as_ref(),
Self::Project(input) => input.fs_client.as_ref(),
}
}
fn repeated_conflict_state_error(&self, detail: &str) -> String {
match self {
Self::Session(_) => format!(
"Rebase assistance made no progress: repeated identical conflict state. Last \
detail: {detail}"
),
Self::Project(_) => format!(
"Sync rebase assistance made no progress: repeated identical conflict state. Last \
detail: {detail}"
),
}
}
fn unchanged_conflict_files_error(&self) -> String {
match self {
Self::Session(_) => {
"Rebase assistance made no progress: conflicted files did not change".to_string()
}
Self::Project(_) => "Sync rebase assistance made no progress: conflicted files did \
not change"
.to_string(),
}
}
fn still_conflicted_error(&self, detail: &str) -> String {
match self {
Self::Session(_) => format!("Rebase still has conflicts after assistance: {detail}"),
Self::Project(_) => {
format!("Sync rebase still has conflicts after assistance: {detail}")
}
}
}
fn exhausted_error(&self) -> String {
match self {
Self::Session(_) => "Failed to complete assisted rebase".to_string(),
Self::Project(_) => "Failed to complete assisted sync rebase".to_string(),
}
}
async fn load_conflicted_files(
&self,
previous_conflict_files: &[String],
) -> Result<Vec<String>, SessionError> {
match self {
Self::Session(input) => {
SessionManager::load_conflicted_files(input, previous_conflict_files).await
}
Self::Project(input) => {
SessionManager::load_sync_conflicted_files(input, previous_conflict_files).await
}
}
}
async fn run_assist_attempt(
&self,
assist_attempt: usize,
conflicted_files: &[String],
) -> Result<(), SessionError> {
match self {
Self::Session(input) => {
SessionManager::append_rebase_assist_header(
input,
assist_attempt,
conflicted_files,
)
.await;
SessionManager::run_rebase_assist_agent(input, conflicted_files).await
}
Self::Project(input) => {
SessionManager::run_sync_rebase_assist_agent(input, conflicted_files).await
}
}
}
async fn stage_and_check_for_conflicts(
&self,
conflict_files: &[String],
) -> Result<bool, SessionError> {
match self {
Self::Session(input) => {
SessionManager::stage_and_check_for_conflicts(input, conflict_files).await
}
Self::Project(input) => {
SessionManager::stage_and_check_for_sync_conflicts(input, conflict_files).await
}
}
}
async fn run_rebase_continue(&self) -> Result<git::RebaseStepResult, SessionError> {
match self {
Self::Session(input) => SessionManager::run_rebase_continue(input).await,
Self::Project(input) => SessionManager::run_sync_rebase_continue(input).await,
}
}
async fn abort_rebase_after_assist_failure(&self) {
match self {
Self::Session(input) => {
SessionManager::abort_rebase_after_assist_failure(input).await;
}
Self::Project(input) => {
SessionManager::abort_sync_rebase_after_assist_failure(input).await;
}
}
}
}
#[cfg_attr(test, mockall::automock)]
trait SyncAssistClient: Send + Sync {
fn resolve_rebase_conflicts(
&self,
folder: PathBuf,
prompt: String,
session_model: AgentModel,
) -> SyncAssistFuture<Result<(), SessionError>>;
}
struct RealSyncAssistClient;
impl RealSyncAssistClient {
async fn run_assist_command(
folder: PathBuf,
prompt: String,
session_model: AgentModel,
) -> Result<(), SessionError> {
let _ = agent::submit_one_shot(agent::OneShotRequest {
child_pid: None,
folder: &folder,
model: session_model,
prompt: &prompt,
request_kind: crate::infra::channel::AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
})
.await
.map_err(SessionError::Workflow)?;
Ok(())
}
}
impl SyncAssistClient for RealSyncAssistClient {
fn resolve_rebase_conflicts(
&self,
folder: PathBuf,
prompt: String,
session_model: AgentModel,
) -> SyncAssistFuture<Result<(), SessionError>> {
Box::pin(async move { Self::run_assist_command(folder, prompt, session_model).await })
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum SyncSessionStartError {
MainHasUncommittedChanges { default_branch: String },
Other(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SyncMainOutcome {
pub(crate) pulled_commit_titles: Vec<String>,
pub(crate) pulled_commits: Option<u32>,
pub(crate) pushed_commit_titles: Vec<String>,
pub(crate) pushed_commits: Option<u32>,
pub(crate) resolved_conflict_files: Vec<String>,
}
#[derive(Debug)]
struct RebaseAssistOutcome {
resolved_conflict_files: Vec<String>,
}
impl RebaseAssistOutcome {
fn empty() -> Self {
Self {
resolved_conflict_files: Vec::new(),
}
}
fn extend_resolved_conflict_files(&mut self, conflict_files: &[String]) {
for conflict_file in conflict_files {
if !self.resolved_conflict_files.contains(conflict_file) {
self.resolved_conflict_files.push(conflict_file.clone());
}
}
self.resolved_conflict_files.sort_unstable();
}
}
impl SyncSessionStartError {
pub(crate) fn detail_message(&self) -> String {
match self {
Self::MainHasUncommittedChanges { default_branch } => format!(
"Sync cannot run while `{default_branch}` has uncommitted changes.\nCommit or \
stash changes in `{default_branch}`, then try again."
),
Self::Other(detail) => detail.clone(),
}
}
}
impl SessionMergeService {
async fn merge_session(
&self,
manager: &SessionManager,
session_id: &str,
projects: &ProjectManager,
services: &AppServices,
) -> Result<(), SessionError> {
let session = manager
.session_or_err(session_id)
.map_err(|_| SessionError::NotFound)?;
if !(session.status.allows_review_actions() || session.status == Status::Queued) {
return Err(SessionError::Workflow(
"Session must be in review or queued status".to_string(),
));
}
let db = services.db().clone();
let folder = session.folder.clone();
let id = session.id.clone();
let session_model = session.model;
let app_event_tx = services.event_sender();
let clock = services.clock();
let fs_client = services.fs_client();
let git_client = manager.git_client();
let handles = manager
.session_handles_or_err(session_id)
.map_err(|_| SessionError::HandlesNotFound)?;
let child_pid = Arc::clone(&handles.child_pid);
let output = Arc::clone(&handles.output);
let status = Arc::clone(&handles.status);
if !SessionTaskService::update_status(
&status,
clock.as_ref(),
&db,
&app_event_tx,
&id,
Status::Merging,
)
.await
{
return Err(SessionError::Workflow(
"Invalid status transition to Merging".to_string(),
));
}
let base_branch = match db.get_session_base_branch(&id).await {
Ok(Some(base_branch)) => base_branch,
Ok(None) => {
let _ = SessionTaskService::update_status(
&status,
clock.as_ref(),
&db,
&app_event_tx,
&id,
Status::Review,
)
.await;
return Err(SessionError::Workflow(
"No git worktree for this session".to_string(),
));
}
Err(error) => {
let _ = SessionTaskService::update_status(
&status,
clock.as_ref(),
&db,
&app_event_tx,
&id,
Status::Review,
)
.await;
return Err(SessionError::Db(error));
}
};
let working_dir = projects.working_dir().to_path_buf();
let Some(repo_root) = git_client.find_git_repo_root(working_dir).await else {
let _ = SessionTaskService::update_status(
&status,
clock.as_ref(),
&db,
&app_event_tx,
&id,
Status::Review,
)
.await;
return Err(SessionError::Workflow(
"Failed to find git repository root".to_string(),
));
};
let merge_task_input = MergeTaskInput {
app_event_tx,
base_branch,
child_pid,
clock,
db,
folder,
fs_client,
git_client,
id: id.clone(),
output,
repo_root,
session_model,
source_branch: session_branch(&id),
status,
};
tokio::spawn(async move {
SessionManager::run_merge_task(merge_task_input).await;
});
Ok(())
}
async fn rebase_session(
&self,
manager: &SessionManager,
services: &AppServices,
session_id: &str,
) -> Result<(), SessionError> {
let session = manager
.session_or_err(session_id)
.map_err(|_| SessionError::NotFound)?;
if !session.status.allows_review_actions() {
return Err(SessionError::Workflow(
"Session must be in review status".to_string(),
));
}
let base_branch = services
.db()
.get_session_base_branch(&session.id)
.await?
.ok_or_else(|| {
SessionError::Workflow("No git worktree for this session".to_string())
})?;
let handles = manager
.session_handles_or_err(session_id)
.map_err(|_| SessionError::HandlesNotFound)?;
let child_pid = Arc::clone(&handles.child_pid);
let output = Arc::clone(&handles.output);
let status = Arc::clone(&handles.status);
let db = services.db().clone();
let app_event_tx = services.event_sender();
let clock = services.clock();
let fs_client = services.fs_client();
let git_client = manager.git_client();
if !SessionTaskService::update_status(
&status,
clock.as_ref(),
&db,
&app_event_tx,
&session.id,
Status::Rebasing,
)
.await
{
return Err(SessionError::Workflow(
"Invalid status transition to Rebasing".to_string(),
));
}
let id = session.id.clone();
let session_model = session.model;
let rebase_task_input = RebaseTaskInput {
app_event_tx,
base_branch,
child_pid,
clock,
db,
folder: session.folder.clone(),
fs_client,
git_client,
id,
output,
session_model,
status,
};
tokio::spawn(async move {
SessionManager::run_rebase_task(rebase_task_input).await;
});
Ok(())
}
}
impl SessionManager {
pub async fn merge_session(
&self,
session_id: &str,
projects: &ProjectManager,
services: &AppServices,
) -> Result<(), SessionError> {
self.merge_service()
.merge_session(self, session_id, projects, services)
.await
}
async fn run_merge_task(input: MergeTaskInput) {
let output = Arc::clone(&input.output);
let clock = Arc::clone(&input.clock);
let db = input.db.clone();
let app_event_tx = input.app_event_tx.clone();
let id = input.id.clone();
let status = Arc::clone(&input.status);
let merge_result = Self::execute_merge_workflow(input).await;
Self::finalize_merge_task(
merge_result,
clock.as_ref(),
&output,
&db,
&app_event_tx,
&id,
&status,
)
.await;
}
async fn execute_merge_workflow(input: MergeTaskInput) -> Result<String, SessionError> {
let rebase_input = Self::merge_rebase_input(&input);
let MergeTaskInput {
app_event_tx,
base_branch,
clock,
db,
folder,
fs_client,
git_client,
id,
output: _,
repo_root,
source_branch,
status,
..
} = input;
if let Err(error) = Self::execute_rebase_workflow(rebase_input).await {
return Err(SessionError::Workflow(format!(
"Merge failed during rebase step: {error}"
)));
}
let squash_diff = Self::load_squash_diff(
git_client.as_ref(),
repo_root.clone(),
source_branch.clone(),
base_branch.clone(),
)
.await?;
let authoritative_commit_message = if squash_diff.trim().is_empty() {
None
} else {
Some(
Self::load_authoritative_session_commit_message(
git_client.as_ref(),
folder.clone(),
)
.await?,
)
};
let merge_outcome = if let Some(commit_message) = authoritative_commit_message.as_ref() {
let repo_root = repo_root.clone();
let source_branch = source_branch.clone();
let base_branch = base_branch.clone();
let commit_message = commit_message.clone();
git_client
.squash_merge(repo_root, source_branch, base_branch, commit_message)
.await?
} else {
git::SquashMergeOutcome::AlreadyPresentInTarget
};
Self::cleanup_merged_session_worktree(
folder.clone(),
Arc::clone(&fs_client),
Arc::clone(&git_client),
source_branch.clone(),
Some(repo_root),
)
.await
.map_err(|error| {
SessionError::Workflow(format!(
"Merged successfully but failed to remove worktree: {error}"
))
})?;
if let Some(commit_message) = authoritative_commit_message {
Self::update_session_title_from_commit_message(
&db,
&id,
&commit_message,
&app_event_tx,
)
.await;
Self::update_done_session_summary_from_commit_message(&db, &id, &commit_message).await;
}
if !SessionTaskService::update_status(
&status,
clock.as_ref(),
&db,
&app_event_tx,
&id,
Status::Done,
)
.await
{
return Err(SessionError::Workflow(
"Invalid status transition to Done".to_string(),
));
}
Ok(Self::merge_success_message(
&source_branch,
&base_branch,
merge_outcome,
))
}
fn merge_rebase_input(input: &MergeTaskInput) -> RebaseAssistInput {
RebaseAssistInput {
app_event_tx: input.app_event_tx.clone(),
base_branch: input.base_branch.clone(),
child_pid: Arc::clone(&input.child_pid),
db: input.db.clone(),
folder: input.folder.clone(),
fs_client: Arc::clone(&input.fs_client),
git_client: Arc::clone(&input.git_client),
id: input.id.clone(),
output: Arc::clone(&input.output),
session_model: input.session_model,
}
}
async fn load_squash_diff(
git_client: &dyn GitClient,
repo_root: PathBuf,
source_branch: String,
base_branch: String,
) -> Result<String, SessionError> {
git_client
.squash_merge_diff(repo_root, source_branch, base_branch)
.await
.map_err(|error| {
SessionError::Workflow(format!("Failed to inspect merge diff: {error}"))
})
}
async fn load_authoritative_session_commit_message(
git_client: &dyn GitClient,
folder: PathBuf,
) -> Result<String, SessionError> {
let commit_message = git_client.head_commit_message(folder).await?;
let Some(commit_message) = commit_message else {
return Err(SessionError::Workflow(
"Session branch has no commit message to reuse for merge".to_string(),
));
};
let trimmed_commit_message = commit_message.trim();
if trimmed_commit_message.is_empty() {
return Err(SessionError::Workflow(
"Session branch has a blank commit message to reuse for merge".to_string(),
));
}
Ok(trimmed_commit_message.to_string())
}
async fn finalize_merge_task(
merge_result: Result<String, SessionError>,
clock: &dyn Clock,
output: &Arc<Mutex<String>>,
db: &Database,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
id: &str,
status: &Arc<Mutex<Status>>,
) {
match merge_result {
Ok(message) => {
let merge_message = format!("\n[Merge] {message}\n");
SessionTaskService::append_session_output(
output,
db,
app_event_tx,
id,
&merge_message,
)
.await;
SessionTaskService::request_git_status_refresh(app_event_tx);
}
Err(error) => {
let merge_error = format!("\n[Merge Error] {error}\n");
SessionTaskService::append_session_output(
output,
db,
app_event_tx,
id,
&merge_error,
)
.await;
let _ = SessionTaskService::update_status(
status,
clock,
db,
app_event_tx,
id,
Status::Review,
)
.await;
}
}
}
fn merge_success_message(
source_branch: &str,
base_branch: &str,
merge_outcome: git::SquashMergeOutcome,
) -> String {
match merge_outcome {
git::SquashMergeOutcome::Committed => {
format!("Successfully merged {source_branch} into {base_branch}")
}
git::SquashMergeOutcome::AlreadyPresentInTarget => {
format!("Session changes from {source_branch} are already present in {base_branch}")
}
}
}
pub async fn rebase_session(
&self,
services: &AppServices,
session_id: &str,
) -> Result<(), SessionError> {
self.merge_service()
.rebase_session(self, services, session_id)
.await
}
pub(crate) async fn sync_main_for_project(
default_branch: Option<String>,
working_dir: PathBuf,
git_client: Arc<dyn GitClient>,
session_model: AgentModel,
) -> Result<SyncMainOutcome, SyncSessionStartError> {
let fs_client: Arc<dyn FsClient> = Arc::new(fs::RealFsClient);
let sync_assist_client: Arc<dyn SyncAssistClient> = Arc::new(RealSyncAssistClient);
Self::sync_main_for_project_with_assist_client(
default_branch,
working_dir,
fs_client,
git_client,
session_model,
sync_assist_client,
)
.await
}
async fn sync_main_for_project_with_assist_client(
default_branch: Option<String>,
working_dir: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
session_model: AgentModel,
sync_assist_client: Arc<dyn SyncAssistClient>,
) -> Result<SyncMainOutcome, SyncSessionStartError> {
let default_branch = default_branch.ok_or_else(|| {
SyncSessionStartError::Other("Active project has no git branch".to_string())
})?;
let _repo_root = git_client
.find_git_repo_root(working_dir.clone())
.await
.ok_or_else(|| {
SyncSessionStartError::Other("Failed to find git repository root".to_string())
})?;
let is_default_branch_clean = git_client
.is_worktree_clean(working_dir.clone())
.await
.map_err(|error| SyncSessionStartError::Other(error.to_string()))?;
if !is_default_branch_clean {
return Err(SyncSessionStartError::MainHasUncommittedChanges {
default_branch: default_branch.clone(),
});
}
let ahead_behind_before_pull = git_client.get_ahead_behind(working_dir.clone()).await.ok();
let pulled_commit_titles = git_client
.list_upstream_commit_titles(working_dir.clone())
.await
.unwrap_or_default();
let pull_result = git_client
.pull_rebase(working_dir.clone())
.await
.map_err(|error| SyncSessionStartError::Other(error.to_string()))?;
let mut resolved_conflict_files = Vec::new();
if let git::PullRebaseResult::Conflict { detail } = pull_result {
let sync_rebase_input = SyncRebaseAssistInput {
base_branch: default_branch.clone(),
folder: working_dir.clone(),
fs_client: Arc::clone(&fs_client),
git_client: Arc::clone(&git_client),
session_model,
sync_assist_client,
};
resolved_conflict_files =
match Self::run_sync_rebase_assist_loop(sync_rebase_input, detail.clone()).await {
Ok(resolved_conflict_files) => resolved_conflict_files,
Err(error) => {
return Err(SyncSessionStartError::Other(format!(
"Sync stopped on rebase conflicts while updating `{default_branch}`: \
{detail}. Assisted resolution failed: {error}"
)));
}
};
}
let ahead_behind_after_pull = git_client.get_ahead_behind(working_dir.clone()).await.ok();
let pushed_commit_titles = git_client
.list_local_commit_titles(working_dir.clone())
.await
.unwrap_or_default();
git_client
.push_current_branch(working_dir)
.await
.map_err(|error| SyncSessionStartError::Other(error.to_string()))?;
let (pulled_commits, pushed_commits) = Self::summarize_sync_ahead_behind_counts(
ahead_behind_before_pull,
ahead_behind_after_pull,
);
Ok(SyncMainOutcome {
pulled_commit_titles,
pulled_commits,
pushed_commit_titles,
pushed_commits,
resolved_conflict_files,
})
}
async fn run_sync_rebase_assist_loop(
input: SyncRebaseAssistInput,
initial_conflict_detail: String,
) -> Result<Vec<String>, SessionError> {
Self::run_rebase_assist_loop_core(
RebaseAssistLoopInput::Project(input),
Some(initial_conflict_detail),
)
.await
.map(|outcome| outcome.resolved_conflict_files)
}
fn summarize_sync_ahead_behind_counts(
ahead_behind_before_pull: Option<(u32, u32)>,
ahead_behind_after_pull: Option<(u32, u32)>,
) -> (Option<u32>, Option<u32>) {
let pulled_commits = ahead_behind_before_pull.map(|(_ahead, behind)| behind);
let pushed_commits = ahead_behind_after_pull
.map(|(ahead, _behind)| ahead)
.or_else(|| ahead_behind_before_pull.map(|(ahead, _behind)| ahead));
(pulled_commits, pushed_commits)
}
async fn load_sync_conflicted_files(
input: &SyncRebaseAssistInput,
previous_conflict_files: &[String],
) -> Result<Vec<String>, SessionError> {
let folder = input.folder.clone();
let mut conflicted = input
.git_client
.list_conflicted_files(folder.clone())
.await?;
let staged_with_markers = input
.git_client
.list_staged_conflict_marker_files(folder, previous_conflict_files.to_vec())
.await?;
for file in staged_with_markers {
if !conflicted.contains(&file) {
conflicted.push(file);
}
}
conflicted.sort_unstable();
Ok(conflicted)
}
async fn run_sync_rebase_assist_agent(
input: &SyncRebaseAssistInput,
conflicted_files: &[String],
) -> Result<(), SessionError> {
let prompt = Self::rebase_assist_prompt(&input.base_branch, conflicted_files)?;
input
.sync_assist_client
.resolve_rebase_conflicts(input.folder.clone(), prompt, input.session_model)
.await
.map_err(|error| error.with_context("Sync rebase assistance failed"))
}
async fn stage_and_check_for_sync_conflicts(
input: &SyncRebaseAssistInput,
conflict_files: &[String],
) -> Result<bool, SessionError> {
let folder = input.folder.clone();
input.git_client.stage_all(folder).await?;
let folder = input.folder.clone();
if input.git_client.has_unmerged_paths(folder).await? {
return Ok(true);
}
let folder = input.folder.clone();
let staged_with_markers = input
.git_client
.list_staged_conflict_marker_files(folder, conflict_files.to_vec())
.await?;
Ok(!staged_with_markers.is_empty())
}
async fn run_sync_rebase_continue(
input: &SyncRebaseAssistInput,
) -> Result<git::RebaseStepResult, SessionError> {
let folder = input.folder.clone();
let result = input.git_client.rebase_continue(folder).await?;
Ok(result)
}
async fn abort_sync_rebase_after_assist_failure(input: &SyncRebaseAssistInput) {
let folder = input.folder.clone();
let _ = input.git_client.abort_rebase(folder).await;
}
async fn run_rebase_task(input: RebaseTaskInput) {
let RebaseTaskInput {
app_event_tx,
base_branch,
child_pid,
clock,
db,
folder,
fs_client,
git_client,
id,
output,
session_model,
status,
} = input;
let rebase_result: Result<String, SessionError> = async {
let rebase_input = RebaseAssistInput {
app_event_tx: app_event_tx.clone(),
base_branch: base_branch.clone(),
child_pid: Arc::clone(&child_pid),
db: db.clone(),
folder: folder.clone(),
fs_client: Arc::clone(&fs_client),
git_client: Arc::clone(&git_client),
id: id.clone(),
output: Arc::clone(&output),
session_model,
};
Self::execute_rebase_workflow(rebase_input).await
}
.await;
Self::finalize_rebase_task(
rebase_result,
clock.as_ref(),
&output,
&db,
&app_event_tx,
&id,
&status,
)
.await;
}
async fn execute_rebase_workflow(input: RebaseAssistInput) -> Result<String, SessionError> {
let include_coauthored_by_agentty =
SessionTaskService::load_include_coauthored_by_agentty_setting(&input.db, &input.id)
.await;
let auto_commit_model = SessionTaskService::load_auto_commit_model_setting(
&input.db,
&input.id,
input.session_model,
)
.await;
match SessionTaskService::commit_session_changes(
input.git_client.as_ref(),
&input.folder,
&input.base_branch,
auto_commit_model,
true,
include_coauthored_by_agentty,
)
.await
{
Ok(outcome) => {
Self::update_session_title_from_commit_message(
&input.db,
&input.id,
&outcome.commit_message,
&input.app_event_tx,
)
.await;
let commit_message =
format!("\n[Commit] committed with hash `{}`\n", outcome.commit_hash);
SessionTaskService::append_session_output(
&input.output,
&input.db,
&input.app_event_tx,
&input.id,
&commit_message,
)
.await;
SessionTaskService::request_git_status_refresh(&input.app_event_tx);
}
Err(error) if error.to_string().contains("Nothing to commit") => {
let commit_message = "\n[Commit] No changes to commit.\n";
SessionTaskService::append_session_output(
&input.output,
&input.db,
&input.app_event_tx,
&input.id,
commit_message,
)
.await;
}
Err(error) => {
return Err(SessionError::Workflow(format!(
"Failed to commit pending changes before rebase: {error}"
)));
}
}
if let Err(error) = Self::run_rebase_assist_loop(input.clone()).await {
Self::abort_rebase_after_assist_failure(&input).await;
return Err(SessionError::Workflow(format!("Failed to rebase: {error}")));
}
let source_branch = session_branch(&input.id);
let base_branch = &input.base_branch;
Ok(format!(
"Successfully rebased {source_branch} onto {base_branch}"
))
}
async fn finalize_rebase_task(
rebase_result: Result<String, SessionError>,
clock: &dyn Clock,
output: &Arc<Mutex<String>>,
db: &Database,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
id: &str,
status: &Arc<Mutex<Status>>,
) {
match rebase_result {
Ok(message) => {
let rebase_message = format!("\n[Rebase] {message}\n");
SessionTaskService::append_session_output(
output,
db,
app_event_tx,
id,
&rebase_message,
)
.await;
SessionTaskService::request_git_status_refresh(app_event_tx);
}
Err(error) => {
let rebase_error = format!("\n[Rebase Error] {error}\n");
SessionTaskService::append_session_output(
output,
db,
app_event_tx,
id,
&rebase_error,
)
.await;
}
}
let _ =
SessionTaskService::update_status(status, clock, db, app_event_tx, id, Status::Review)
.await;
}
pub(crate) async fn update_session_title_from_commit_message(
db: &Database,
session_id: &str,
commit_message: &str,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
) {
let title = Self::session_title_from_commit_message(commit_message);
let _ = db.update_session_title(session_id, &title).await;
let _ = app_event_tx.send(AppEvent::RefreshSessions);
}
async fn update_done_session_summary_from_commit_message(
db: &Database,
session_id: &str,
commit_message: &str,
) {
let summary = Self::session_summary_with_commit_message(
Self::persisted_session_summary(db, session_id)
.await
.as_deref(),
commit_message,
);
let _ = db.update_session_summary(session_id, &summary).await;
}
async fn persisted_session_summary(db: &Database, session_id: &str) -> Option<String> {
db.load_session_summary(session_id).await.ok().flatten()
}
fn session_title_from_commit_message(commit_message: &str) -> String {
let trimmed_message = commit_message.trim();
if trimmed_message.is_empty() {
return "Apply session updates".to_string();
}
trimmed_message
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("Apply session updates")
.to_string()
}
fn session_summary_with_commit_message(
session_summary: Option<&str>,
commit_message: &str,
) -> String {
let trimmed_summary = session_summary.map(str::trim).unwrap_or_default();
let summary_text = serde_json::from_str::<AgentResponseSummary>(trimmed_summary)
.map_or_else(
|_| trimmed_summary.to_string(),
|summary_payload| summary_payload.session,
);
let trimmed_commit_message = commit_message.trim();
format!("# Summary\n\n{summary_text}\n\n# Commit\n\n{trimmed_commit_message}")
}
async fn run_rebase_assist_loop(input: RebaseAssistInput) -> Result<(), SessionError> {
let rebase_in_progress = Self::is_rebase_in_progress(&input).await?;
if !rebase_in_progress {
let initial_step = Self::run_rebase_start(&input).await?;
if initial_step == git::RebaseStepResult::Completed {
return Ok(());
}
}
Self::run_rebase_assist_loop_core(RebaseAssistLoopInput::Session(input), None)
.await
.map(|_| ())
}
async fn run_rebase_assist_loop_core(
assist_input: RebaseAssistLoopInput,
initial_conflict_detail: Option<String>,
) -> Result<RebaseAssistOutcome, SessionError> {
let assist_result: Result<RebaseAssistOutcome, SessionError> = async {
let mut failure_tracker =
FailureTracker::new(REBASE_ASSIST_POLICY.max_identical_failure_streak);
let mut assist_outcome = RebaseAssistOutcome::empty();
if let Some(initial_conflict_detail) = initial_conflict_detail {
let _ = failure_tracker.observe(&initial_conflict_detail);
}
let mut previous_conflict_files: Vec<String> = vec![];
for assist_attempt in 1..=REBASE_ASSIST_POLICY.max_attempts {
let conflicted_files = assist_input
.load_conflicted_files(&previous_conflict_files)
.await?;
if conflicted_files.is_empty() {
let continue_step = assist_input.run_rebase_continue().await?;
match continue_step {
git::RebaseStepResult::Completed => {
return Ok(assist_outcome);
}
git::RebaseStepResult::Conflict { detail } => {
if failure_tracker.observe(&detail) {
return Err(SessionError::Workflow(
assist_input.repeated_conflict_state_error(&detail),
));
}
if assist_attempt == REBASE_ASSIST_POLICY.max_attempts {
return Err(SessionError::Workflow(
assist_input.still_conflicted_error(&detail),
));
}
}
}
continue;
}
let conflict_fingerprint = Self::conflicted_file_fingerprint(
assist_input.fs_client(),
assist_input.folder(),
&conflicted_files,
)
.await;
if failure_tracker.observe(&conflict_fingerprint) {
return Err(SessionError::Workflow(
assist_input.unchanged_conflict_files_error(),
));
}
assist_outcome.extend_resolved_conflict_files(&conflicted_files);
assist_input
.run_assist_attempt(assist_attempt, &conflicted_files)
.await?;
let still_has_conflicts = assist_input
.stage_and_check_for_conflicts(&conflicted_files)
.await?;
previous_conflict_files = conflicted_files;
if still_has_conflicts {
if assist_attempt == REBASE_ASSIST_POLICY.max_attempts {
return Err(SessionError::Workflow(
"Conflicts remain unresolved after maximum assistance attempts"
.to_string(),
));
}
continue;
}
let continue_step = assist_input.run_rebase_continue().await?;
match continue_step {
git::RebaseStepResult::Completed => {
return Ok(assist_outcome);
}
git::RebaseStepResult::Conflict { detail } => {
if failure_tracker.observe(&detail) {
return Err(SessionError::Workflow(
assist_input.repeated_conflict_state_error(&detail),
));
}
if assist_attempt == REBASE_ASSIST_POLICY.max_attempts {
return Err(SessionError::Workflow(
assist_input.still_conflicted_error(&detail),
));
}
}
}
}
Err(SessionError::Workflow(assist_input.exhausted_error()))
}
.await;
match assist_result {
Ok(assist_outcome) => Ok(assist_outcome),
Err(error) => {
assist_input.abort_rebase_after_assist_failure().await;
Err(error)
}
}
}
async fn is_rebase_in_progress(input: &RebaseAssistInput) -> Result<bool, SessionError> {
let folder = input.folder.clone();
let is_rebase_in_progress = input.git_client.is_rebase_in_progress(folder).await?;
Ok(is_rebase_in_progress)
}
async fn run_rebase_start(
input: &RebaseAssistInput,
) -> Result<git::RebaseStepResult, SessionError> {
let folder = input.folder.clone();
let base_branch = input.base_branch.clone();
match input
.git_client
.rebase_start(folder.clone(), base_branch.clone())
.await
{
Ok(result) => Ok(result),
Err(error) => {
let error_string = error.to_string();
if !Self::is_stale_rebase_state_error(&error_string) {
return Err(SessionError::Workflow(error_string));
}
Self::recover_from_stale_rebase_start_error(input, &error_string).await?;
input
.git_client
.rebase_start(folder, base_branch)
.await
.map_err(SessionError::Git)
}
}
}
fn is_stale_rebase_state_error(error: &str) -> bool {
let normalized_error = error.to_ascii_lowercase();
normalized_error.contains("already a rebase-merge directory")
|| normalized_error.contains("already a rebase-apply directory")
|| normalized_error.contains("middle of another rebase")
}
async fn recover_from_stale_rebase_start_error(
input: &RebaseAssistInput,
start_error: &str,
) -> Result<(), SessionError> {
let folder = input.folder.clone();
input
.git_client
.abort_rebase(folder)
.await
.map_err(|abort_error| {
SessionError::Workflow(format!(
"Detected stale rebase metadata after failed rebase start: {start_error}. \
Cleanup with `git rebase --abort` failed: {abort_error}"
))
})?;
Ok(())
}
async fn load_conflicted_files(
input: &RebaseAssistInput,
previous_conflict_files: &[String],
) -> Result<Vec<String>, SessionError> {
let folder = input.folder.clone();
let mut conflicted = input
.git_client
.list_conflicted_files(folder.clone())
.await?;
let staged_with_markers = input
.git_client
.list_staged_conflict_marker_files(folder, previous_conflict_files.to_vec())
.await?;
for file in staged_with_markers {
if !conflicted.contains(&file) {
conflicted.push(file);
}
}
conflicted.sort_unstable();
Ok(conflicted)
}
async fn append_rebase_assist_header(
input: &RebaseAssistInput,
assist_attempt: usize,
conflicted_files: &[String],
) {
let conflict_summary = Self::format_conflicted_file_list(conflicted_files);
append_assist_header(
&Self::assist_context(input),
"Rebase",
assist_attempt,
REBASE_ASSIST_POLICY.max_attempts,
"Resolving conflicts in:",
&conflict_summary,
)
.await;
}
async fn run_rebase_assist_agent(
input: &RebaseAssistInput,
conflicted_files: &[String],
) -> Result<(), SessionError> {
let prompt = Self::rebase_assist_prompt(&input.base_branch, conflicted_files)?;
let assist_context = Self::assist_context(input);
run_agent_assist(&assist_context, &prompt)
.await
.map_err(|error| error.with_context("Rebase assistance failed"))
}
async fn stage_and_check_for_conflicts(
input: &RebaseAssistInput,
conflict_files: &[String],
) -> Result<bool, SessionError> {
let folder = input.folder.clone();
input.git_client.stage_all(folder).await?;
let folder = input.folder.clone();
if input.git_client.has_unmerged_paths(folder).await? {
return Ok(true);
}
let folder = input.folder.clone();
let staged_with_markers = input
.git_client
.list_staged_conflict_marker_files(folder, conflict_files.to_vec())
.await?;
Ok(!staged_with_markers.is_empty())
}
async fn run_rebase_continue(
input: &RebaseAssistInput,
) -> Result<git::RebaseStepResult, SessionError> {
let folder = input.folder.clone();
let result = input.git_client.rebase_continue(folder).await?;
Ok(result)
}
fn rebase_assist_prompt(
base_branch: &str,
conflicted_files: &[String],
) -> Result<String, SessionError> {
let conflicted_files = Self::format_conflicted_file_list(conflicted_files);
let template = RebaseAssistPromptTemplate {
base_branch,
conflicted_files: &conflicted_files,
};
template.render().map_err(|error| {
SessionError::Workflow(format!(
"Failed to render `rebase_assist_prompt.md`: {error}"
))
})
}
fn format_conflicted_file_list(conflicted_files: &[String]) -> String {
format_detail_lines(&conflicted_files.join("\n"))
}
async fn conflicted_file_fingerprint(
fs_client: &dyn FsClient,
folder: &Path,
conflicted_files: &[String],
) -> String {
let mut sorted_files = conflicted_files.to_vec();
sorted_files.sort_unstable();
let mut hasher = DefaultHasher::new();
for file in &sorted_files {
file.hash(&mut hasher);
let file_path = folder.join(file);
if let Ok(content) = fs_client.read_file(file_path).await {
content.hash(&mut hasher);
}
}
format!("{:016x}", hasher.finish())
}
fn assist_context(input: &RebaseAssistInput) -> AssistContext {
AssistContext {
app_event_tx: input.app_event_tx.clone(),
child_pid: Arc::clone(&input.child_pid),
db: input.db.clone(),
folder: input.folder.clone(),
git_client: Arc::clone(&input.git_client),
id: input.id.clone(),
output: Arc::clone(&input.output),
session_model: input.session_model,
}
}
async fn abort_rebase_after_assist_failure(input: &RebaseAssistInput) {
let folder = input.folder.clone();
let _ = input.git_client.abort_rebase(folder).await;
}
pub(crate) async fn cleanup_merged_session_worktree(
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
source_branch: String,
repo_root: Option<PathBuf>,
) -> Result<(), SessionError> {
let repo_root = match repo_root {
Some(repo_root) => Some(repo_root),
None => git_client.main_repo_root(folder.clone()).await.ok(),
};
git_client.remove_worktree(folder.clone()).await?;
if let Some(repo_root) = repo_root {
git_client.delete_branch(repo_root, source_branch).await?;
}
let _ = fs_client.remove_dir_all(folder).await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use mockall::Sequence;
use tempfile::{TempDir, tempdir};
use super::*;
use crate::infra::git::GitError;
fn create_passthrough_mock_fs_client() -> fs::MockFsClient {
let mut mock_fs_client = fs::MockFsClient::new();
mock_fs_client
.expect_create_dir_all()
.times(0..)
.returning(|path| {
Box::pin(async move {
tokio::fs::create_dir_all(path)
.await
.map_err(fs::FsError::from)
})
});
mock_fs_client
.expect_remove_dir_all()
.times(0..)
.returning(|path| {
Box::pin(async move {
tokio::fs::remove_dir_all(path)
.await
.map_err(fs::FsError::from)
})
});
mock_fs_client
.expect_read_file()
.times(0..)
.returning(|path| {
Box::pin(async move { tokio::fs::read(path).await.map_err(fs::FsError::from) })
});
mock_fs_client
.expect_remove_file()
.times(0..)
.returning(|_| Box::pin(async { Ok(()) }));
mock_fs_client
.expect_is_dir()
.times(0..)
.returning(|path| path.is_dir());
mock_fs_client
}
fn test_fs_client() -> Arc<dyn FsClient> {
Arc::new(create_passthrough_mock_fs_client())
}
async fn build_rebase_assist_input_for_test(
git_client: Arc<dyn GitClient>,
) -> (TempDir, RebaseAssistInput) {
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let db = Database::open_in_memory().await.expect("failed to open db");
let temp_dir = tempdir().expect("failed to create temporary test directory");
let folder = temp_dir.path().to_path_buf();
(
temp_dir,
RebaseAssistInput {
app_event_tx,
base_branch: "main".to_string(),
child_pid: Arc::new(Mutex::new(None)),
db,
folder,
fs_client: test_fs_client(),
git_client,
id: "session-123".to_string(),
output: Arc::new(Mutex::new(String::new())),
session_model: AgentModel::Gemini3FlashPreview,
},
)
}
async fn build_merge_task_input_for_test(
git_client: Arc<dyn GitClient>,
) -> (TempDir, MergeTaskInput) {
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let db = Database::open_in_memory().await.expect("failed to open db");
let temp_dir = tempdir().expect("failed to create temporary test directory");
let folder = temp_dir.path().join("session-worktree");
let repo_root = temp_dir.path().join("repo-root");
(
temp_dir,
MergeTaskInput {
app_event_tx,
base_branch: "main".to_string(),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db,
folder,
fs_client: test_fs_client(),
git_client,
id: "session-123".to_string(),
output: Arc::new(Mutex::new(String::new())),
repo_root,
session_model: AgentModel::Gemini3FlashPreview,
source_branch: "agentty/session-123".to_string(),
status: Arc::new(Mutex::new(Status::Merging)),
},
)
}
fn build_sync_rebase_input_for_test(
folder: PathBuf,
git_client: Arc<dyn GitClient>,
sync_assist_client: Arc<dyn SyncAssistClient>,
) -> SyncRebaseAssistInput {
SyncRebaseAssistInput {
base_branch: "main".to_string(),
folder,
fs_client: test_fs_client(),
git_client,
session_model: AgentModel::Gemini3FlashPreview,
sync_assist_client,
}
}
#[tokio::test]
async fn test_sync_rebase_assist_agent_adds_context_to_workflow_error() {
let mut mock_sync_assist_client = MockSyncAssistClient::new();
mock_sync_assist_client
.expect_resolve_rebase_conflicts()
.times(1)
.returning(|_, _, _| {
Box::pin(async {
Err(SessionError::Workflow(
"agent backend unavailable".to_string(),
))
})
});
let temp_dir = tempdir().expect("failed to create temporary test directory");
let input = build_sync_rebase_input_for_test(
temp_dir.path().to_path_buf(),
Arc::new(git::MockGitClient::new()),
Arc::new(mock_sync_assist_client),
);
let result =
SessionManager::run_sync_rebase_assist_agent(&input, &["src/lib.rs".to_string()]).await;
let error = result.expect_err("assist failure should propagate");
assert!(
matches!(error, SessionError::Workflow(_)),
"expected SessionError::Workflow, got: {error:?}"
);
assert_eq!(
error.to_string(),
"Sync rebase assistance failed: agent backend unavailable"
);
}
#[test]
fn test_rebase_assist_prompt_includes_branch_and_files() {
let base_branch = "main";
let conflicted_files = vec!["src/lib.rs".to_string(), "README.md".to_string()];
let prompt = SessionManager::rebase_assist_prompt(base_branch, &conflicted_files)
.expect("rebase assist prompt should render");
assert!(prompt.contains("rebasing onto `main`"));
assert!(prompt.contains("- src/lib.rs"));
assert!(prompt.contains("- README.md"));
}
#[test]
fn test_format_conflicted_file_list_returns_bulleted_lines() {
let conflicted_files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
let summary = SessionManager::format_conflicted_file_list(&conflicted_files);
assert_eq!(summary, "- src/main.rs\n- src/lib.rs");
}
#[test]
fn test_session_title_from_commit_message() {
let commit_message = "Refine merge flow\n\n- Update title handling";
let title = SessionManager::session_title_from_commit_message(commit_message);
assert_eq!(title, "Refine merge flow");
}
#[test]
fn test_session_title_from_commit_message_skips_blank_prefix() {
let commit_message = "\n\nRefine merge flow\n\n- Update title handling";
let title = SessionManager::session_title_from_commit_message(commit_message);
assert_eq!(title, "Refine merge flow");
}
#[test]
fn test_session_title_from_commit_message_empty_uses_fallback() {
let commit_message = " \n";
let title = SessionManager::session_title_from_commit_message(commit_message);
assert_eq!(title, "Apply session updates");
}
#[test]
fn test_session_summary_with_commit_message_builds_markdown_sections() {
let session_summary = Some("- Session branch now handles refresh races.");
let commit_message = "Refine session summary\n\n- Append commit context";
let summary =
SessionManager::session_summary_with_commit_message(session_summary, commit_message);
assert_eq!(
summary,
"# Summary\n\n- Session branch now handles refresh races.\n\n# Commit\n\nRefine \
session summary\n\n- Append commit context"
);
}
#[test]
fn test_session_summary_with_commit_message_formats_empty_summary_section() {
let session_summary = Some(" ");
let commit_message = "Refine session summary";
let summary =
SessionManager::session_summary_with_commit_message(session_summary, commit_message);
assert_eq!(
summary,
"# Summary\n\n\n\n# Commit\n\nRefine session summary"
);
}
#[test]
fn test_session_summary_with_commit_message_extracts_session_text_from_json_payload() {
let session_summary = Some(
r#"{"turn":"Updated the greeting flow.","session":"Session now greets users on startup."}"#,
);
let commit_message = "Refine session summary";
let summary =
SessionManager::session_summary_with_commit_message(session_summary, commit_message);
assert_eq!(
summary,
"# Summary\n\nSession now greets users on startup.\n\n# Commit\n\nRefine session \
summary"
);
}
#[tokio::test]
async fn test_update_session_title_from_commit_message_preserves_existing_summary() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let project_id = database
.upsert_project("/tmp/project", Some("main"))
.await
.expect("failed to upsert project");
database
.insert_session(
"session-id",
AgentModel::ClaudeSonnet46.as_str(),
"main",
"Review",
project_id,
)
.await
.expect("failed to insert session");
let existing_summary = "- Session branch updates README.";
database
.update_session_summary("session-id", existing_summary)
.await
.expect("failed to persist existing summary");
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let commit_message = "Refine session commit message\n\n- Keep title in sync";
SessionManager::update_session_title_from_commit_message(
&database,
"session-id",
commit_message,
&app_event_tx,
)
.await;
let sessions = database
.load_sessions()
.await
.expect("failed to load sessions");
assert_eq!(
sessions[0].title.as_deref(),
Some("Refine session commit message")
);
assert_eq!(sessions[0].summary.as_deref(), Some(existing_summary));
assert_eq!(
app_event_rx.try_recv().ok(),
Some(AppEvent::RefreshSessions)
);
}
#[tokio::test]
async fn test_update_done_session_summary_from_commit_message_appends_commit_message() {
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let project_id = database
.upsert_project("/tmp/project", Some("main"))
.await
.expect("failed to upsert project");
database
.insert_session(
"session-id",
AgentModel::ClaudeSonnet46.as_str(),
"main",
"Review",
project_id,
)
.await
.expect("failed to insert session");
let existing_summary = "- Session branch updates README.";
let commit_message = "Refine session commit message\n\n- Keep title in sync";
database
.update_session_summary("session-id", existing_summary)
.await
.expect("failed to persist existing summary");
SessionManager::update_done_session_summary_from_commit_message(
&database,
"session-id",
commit_message,
)
.await;
let sessions = database
.load_sessions()
.await
.expect("failed to load sessions");
assert_eq!(
sessions[0].summary.as_deref(),
Some(
"# Summary\n\n- Session branch updates README.\n\n# Commit\n\nRefine session \
commit message\n\n- Keep title in sync"
)
);
}
#[tokio::test]
async fn test_execute_merge_workflow_reuses_session_head_commit_message() {
let canonical_commit_message = "Refine merge flow\n\n- Reuse the session commit body";
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(true) }));
mock_git_client
.expect_is_rebase_in_progress()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(false) }));
mock_git_client
.expect_rebase_start()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok(git::RebaseStepResult::Completed) }));
mock_git_client
.expect_squash_merge_diff()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _, _| Box::pin(async { Ok("diff --git a/file b/file".to_string()) }));
mock_git_client
.expect_head_commit_message()
.times(1)
.in_sequence(&mut sequence)
.returning({
let canonical_commit_message = canonical_commit_message.to_string();
move |_| {
let canonical_commit_message = canonical_commit_message.clone();
Box::pin(async move { Ok(Some(canonical_commit_message)) })
}
});
mock_git_client
.expect_squash_merge()
.times(1)
.in_sequence(&mut sequence)
.returning({
let canonical_commit_message = canonical_commit_message.to_string();
move |_, _, _, commit_message| {
let canonical_commit_message = canonical_commit_message.clone();
Box::pin(async move {
assert_eq!(commit_message, canonical_commit_message);
Ok(git::SquashMergeOutcome::Committed)
})
}
});
mock_git_client
.expect_remove_worktree()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_delete_branch()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok(()) }));
let (_temp_dir, input) = build_merge_task_input_for_test(Arc::new(mock_git_client)).await;
let result = SessionManager::execute_merge_workflow(input).await;
let message = result.expect("merge workflow should succeed");
assert_eq!(message, "Successfully merged agentty/session-123 into main");
}
#[tokio::test]
async fn test_execute_merge_workflow_skips_commit_creation_for_empty_squash_diff() {
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(true) }));
mock_git_client
.expect_is_rebase_in_progress()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(false) }));
mock_git_client
.expect_rebase_start()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok(git::RebaseStepResult::Completed) }));
mock_git_client
.expect_squash_merge_diff()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _, _| Box::pin(async { Ok(" ".to_string()) }));
mock_git_client.expect_head_commit_message().times(0);
mock_git_client.expect_squash_merge().times(0);
mock_git_client
.expect_remove_worktree()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_delete_branch()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok(()) }));
let (_temp_dir, input) = build_merge_task_input_for_test(Arc::new(mock_git_client)).await;
let result = SessionManager::execute_merge_workflow(input).await;
let message = result.expect("merge workflow should succeed for empty diff");
assert_eq!(
message,
"Session changes from agentty/session-123 are already present in main"
);
}
#[tokio::test]
async fn test_rebase_assist_input_clone() {
let (tx, _rx) = mpsc::unbounded_channel();
let db = Database::open_in_memory().await.expect("failed to open db");
let temp_dir = tempdir().expect("failed to create temporary test directory");
let input = RebaseAssistInput {
app_event_tx: tx,
base_branch: "main".to_string(),
child_pid: Arc::new(Mutex::new(None)),
db,
folder: temp_dir.path().to_path_buf(),
fs_client: test_fs_client(),
git_client: Arc::new(git::RealGitClient),
id: "session-123".to_string(),
output: Arc::new(Mutex::new(String::new())),
session_model: AgentModel::Gemini3FlashPreview,
};
let cloned_input = input.clone();
assert_eq!(input.base_branch, cloned_input.base_branch);
assert_eq!(input.id, cloned_input.id);
assert_eq!(input.folder, cloned_input.folder);
assert_eq!(input.session_model, cloned_input.session_model);
}
#[tokio::test]
async fn test_execute_rebase_workflow_aborts_when_assist_loop_fails() {
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(false) }));
mock_git_client
.expect_has_commits_since()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok(true) }));
mock_git_client
.expect_head_commit_message()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(Some("Existing session commit".to_string())) }));
mock_git_client
.expect_commit_all_preserving_single_commit()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _, _, _, _| Box::pin(async { Ok(()) }));
mock_git_client
.expect_head_short_hash()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok("abc1234".to_string()) }));
mock_git_client
.expect_is_rebase_in_progress()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| {
Box::pin(async { Err(GitError::OutputParse("state query failed".to_string())) })
});
mock_git_client
.expect_abort_rebase()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(()) }));
let (_temp_dir, input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
let result = SessionManager::execute_rebase_workflow(input).await;
let error = result.expect_err("rebase workflow should fail");
assert!(
error
.to_string()
.contains("Failed to rebase: state query failed"),
"workflow error should include assist-loop failure reason"
);
}
#[tokio::test]
async fn test_run_rebase_assist_loop_core_aborts_on_early_error() {
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
mock_git_client
.expect_list_conflicted_files()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| {
Box::pin(async {
Err(GitError::OutputParse(
"failed to list conflicts".to_string(),
))
})
});
mock_git_client
.expect_abort_rebase()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(()) }));
let (_temp_dir, input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
let result = SessionManager::run_rebase_assist_loop_core(
RebaseAssistLoopInput::Session(input),
None,
)
.await;
let error = result.expect_err("assist loop should fail");
assert_eq!(error.to_string(), "failed to list conflicts");
}
#[tokio::test]
async fn test_run_rebase_assist_loop_core_stops_on_repeated_conflict_detail() {
let repeated_detail = "CONFLICT (content): Merge conflict in src/lib.rs".to_string();
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_list_conflicted_files()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_| Box::pin(async { Ok(Vec::new()) }));
mock_git_client
.expect_list_staged_conflict_marker_files()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_, _| Box::pin(async { Ok(Vec::new()) }));
mock_git_client
.expect_rebase_continue()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning({
let repeated_detail = repeated_detail.clone();
move |_| {
let repeated_detail = repeated_detail.clone();
Box::pin(async move {
Ok(git::RebaseStepResult::Conflict {
detail: repeated_detail,
})
})
}
});
mock_git_client
.expect_abort_rebase()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let (_temp_dir, input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
let result = SessionManager::run_rebase_assist_loop_core(
RebaseAssistLoopInput::Session(input),
Some(repeated_detail.clone()),
)
.await;
let error = result.expect_err("assist loop should stop on repeated conflict detail");
assert_eq!(
error.to_string(),
format!(
"Rebase assistance made no progress: repeated identical conflict state. Last \
detail: {repeated_detail}"
)
);
}
#[tokio::test]
async fn test_run_rebase_assist_loop_core_reports_retry_exhaustion_detail() {
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
for detail in [
"CONFLICT (content): Merge conflict in src/lib.rs",
"CONFLICT (content): Merge conflict in src/main.rs",
"CONFLICT (content): Merge conflict in README.md",
] {
mock_git_client
.expect_list_conflicted_files()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(Vec::new()) }));
mock_git_client
.expect_list_staged_conflict_marker_files()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok(Vec::new()) }));
mock_git_client
.expect_rebase_continue()
.times(1)
.in_sequence(&mut sequence)
.returning({
let detail = detail.to_string();
move |_| {
let detail = detail.clone();
Box::pin(async move { Ok(git::RebaseStepResult::Conflict { detail }) })
}
});
}
mock_git_client
.expect_abort_rebase()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let (_temp_dir, input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
let result = SessionManager::run_rebase_assist_loop_core(
RebaseAssistLoopInput::Session(input),
None,
)
.await;
let error = result.expect_err("assist loop should report the final retry conflict");
assert_eq!(
error.to_string(),
"Rebase still has conflicts after assistance: CONFLICT (content): Merge conflict in \
README.md"
);
}
#[tokio::test]
async fn test_run_rebase_start_recovers_stale_rebase_state_and_retries() {
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
mock_git_client
.expect_rebase_start()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| {
Box::pin(async {
Err(GitError::OutputParse(
"fatal: It seems that there is already a rebase-merge directory"
.to_string(),
))
})
});
mock_git_client
.expect_abort_rebase()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_rebase_start()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok(git::RebaseStepResult::Completed) }));
let (_temp_dir, input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
let result = SessionManager::run_rebase_start(&input).await;
let step_result = result.expect("rebase start should succeed");
assert_eq!(step_result, git::RebaseStepResult::Completed);
}
#[tokio::test]
async fn test_run_rebase_start_reports_cleanup_failure_for_stale_rebase_state() {
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
mock_git_client
.expect_rebase_start()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| {
Box::pin(async {
Err(GitError::OutputParse(
"fatal: It seems that there is already a rebase-merge directory"
.to_string(),
))
})
});
mock_git_client
.expect_abort_rebase()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| {
Box::pin(async { Err(GitError::OutputParse("abort failed".to_string())) })
});
let (_temp_dir, input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
let result = SessionManager::run_rebase_start(&input).await;
let error = result.expect_err("cleanup failure should stop retry flow");
assert!(
error
.to_string()
.contains("Cleanup with `git rebase --abort` failed: abort failed"),
"error should include abort failure detail"
);
}
#[tokio::test]
async fn test_cleanup_merged_session_worktree_reports_delete_branch_failure() {
let temp_dir = tempdir().expect("failed to create temporary test directory");
let folder = temp_dir.path().join("session-worktree");
let repo_root = temp_dir.path().join("repo-root");
let source_branch = "agentty/session-123".to_string();
let mut mock_git_client = git::MockGitClient::new();
let mut mock_fs_client = fs::MockFsClient::new();
mock_git_client
.expect_remove_worktree()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_delete_branch()
.times(1)
.returning(|_, _| {
Box::pin(async { Err(GitError::OutputParse("delete failed".to_string())) })
});
mock_fs_client.expect_remove_dir_all().times(0);
let result = SessionManager::cleanup_merged_session_worktree(
folder,
Arc::new(mock_fs_client),
Arc::new(mock_git_client),
source_branch,
Some(repo_root),
)
.await;
let error = result.expect_err("cleanup should fail on branch deletion error");
assert_eq!(error.to_string(), "delete failed");
}
#[test]
fn test_detail_message_for_uncommitted_changes_uses_sentence_lines() {
let sync_error = SyncSessionStartError::MainHasUncommittedChanges {
default_branch: "main".to_string(),
};
let detail_message = sync_error.detail_message();
assert_eq!(
detail_message,
"Sync cannot run while `main` has uncommitted changes.\nCommit or stash changes in \
`main`, then try again."
);
}
#[tokio::test]
async fn test_sync_main_for_project_resolves_conflicts_with_assistance() {
let temp_dir = tempdir().expect("failed to create temporary test directory");
let working_dir = temp_dir.path().to_path_buf();
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_find_git_repo_root()
.times(1)
.returning(|folder| Box::pin(async move { Some(folder) }));
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok(true) }));
mock_git_client
.expect_get_ahead_behind()
.times(1)
.return_once(|_| Box::pin(async { Ok((1, 2)) }));
mock_git_client
.expect_list_upstream_commit_titles()
.times(1)
.returning(|_| {
Box::pin(async {
Ok(vec![
"Update changelog format".to_string(),
"Fix sync popup copy".to_string(),
])
})
});
mock_git_client
.expect_get_ahead_behind()
.times(1)
.return_once(|_| Box::pin(async { Ok((1, 0)) }));
mock_git_client
.expect_list_local_commit_titles()
.times(1)
.returning(|_| {
Box::pin(async { Ok(vec!["Refine sync conflict messaging".to_string()]) })
});
mock_git_client
.expect_pull_rebase()
.times(1)
.returning(|_| {
Box::pin(async {
Ok(git::PullRebaseResult::Conflict {
detail: "CONFLICT (content): Merge conflict in src/lib.rs".to_string(),
})
})
});
mock_git_client
.expect_list_conflicted_files()
.times(1)
.returning(|_| Box::pin(async { Ok(vec!["src/lib.rs".to_string()]) }));
mock_git_client
.expect_list_staged_conflict_marker_files()
.times(2)
.returning(|_, _| Box::pin(async { Ok(vec![]) }));
mock_git_client
.expect_stage_all()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_has_unmerged_paths()
.times(1)
.returning(|_| Box::pin(async { Ok(false) }));
mock_git_client
.expect_rebase_continue()
.times(1)
.returning(|_| Box::pin(async { Ok(git::RebaseStepResult::Completed) }));
mock_git_client
.expect_push_current_branch()
.times(1)
.returning(|_| Box::pin(async { Ok("origin/main".to_string()) }));
mock_git_client.expect_abort_rebase().times(0);
let mut mock_sync_assist_client = MockSyncAssistClient::new();
mock_sync_assist_client
.expect_resolve_rebase_conflicts()
.times(1)
.returning(|_, _, _| Box::pin(async { Ok(()) }));
let result = SessionManager::sync_main_for_project_with_assist_client(
Some("main".to_string()),
working_dir,
test_fs_client(),
Arc::new(mock_git_client),
AgentModel::Gemini3FlashPreview,
Arc::new(mock_sync_assist_client),
)
.await;
assert_eq!(
result,
Ok(SyncMainOutcome {
pulled_commit_titles: vec![
"Update changelog format".to_string(),
"Fix sync popup copy".to_string(),
],
pulled_commits: Some(2),
pushed_commit_titles: vec!["Refine sync conflict messaging".to_string()],
pushed_commits: Some(1),
resolved_conflict_files: vec!["src/lib.rs".to_string()],
}),
"sync should succeed after assistance with summary details"
);
}
#[tokio::test]
async fn test_sync_main_for_project_fails_after_max_assistance_attempts() {
let temp_dir = tempdir().expect("failed to create temporary test directory");
let working_dir = temp_dir.path().to_path_buf();
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_find_git_repo_root()
.times(1)
.returning(|folder| Box::pin(async move { Some(folder) }));
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok(true) }));
mock_git_client
.expect_get_ahead_behind()
.times(1)
.returning(|_| Box::pin(async { Ok((0, 1)) }));
mock_git_client
.expect_list_upstream_commit_titles()
.times(1)
.returning(|_| Box::pin(async { Ok(vec!["Upstream patch".to_string()]) }));
mock_git_client
.expect_pull_rebase()
.times(1)
.returning(|_| {
Box::pin(async {
Ok(git::PullRebaseResult::Conflict {
detail: "CONFLICT (content): Merge conflict in src/lib.rs".to_string(),
})
})
});
mock_git_client
.expect_list_conflicted_files()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_| Box::pin(async { Ok(vec!["src/lib.rs".to_string()]) }));
mock_git_client
.expect_list_staged_conflict_marker_files()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_, _| Box::pin(async { Ok(vec![]) }));
mock_git_client
.expect_stage_all()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_has_unmerged_paths()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_| Box::pin(async { Ok(true) }));
mock_git_client.expect_rebase_continue().times(0);
mock_git_client.expect_push_current_branch().times(0);
mock_git_client
.expect_abort_rebase()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let mut mock_sync_assist_client = MockSyncAssistClient::new();
mock_sync_assist_client
.expect_resolve_rebase_conflicts()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_, _, _| Box::pin(async { Ok(()) }));
let result = SessionManager::sync_main_for_project_with_assist_client(
Some("main".to_string()),
working_dir,
test_fs_client(),
Arc::new(mock_git_client),
AgentModel::Gemini3FlashPreview,
Arc::new(mock_sync_assist_client),
)
.await;
let error = result.expect_err("sync should fail when conflicts remain unresolved");
assert!(matches!(error, SyncSessionStartError::Other(_)));
assert!(
error
.detail_message()
.contains("Conflicts remain unresolved after maximum assistance attempts"),
"error detail should mention unresolved conflicts"
);
}
#[tokio::test]
async fn test_load_sync_conflicted_files_merges_and_sorts_results() {
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_list_conflicted_files()
.times(1)
.returning(|_| {
Box::pin(async { Ok(vec!["src/b.rs".to_string(), "src/c.rs".to_string()]) })
});
mock_git_client
.expect_list_staged_conflict_marker_files()
.times(1)
.returning(|_, _| {
Box::pin(async { Ok(vec!["src/a.rs".to_string(), "src/c.rs".to_string()]) })
});
let mut mock_sync_assist_client = MockSyncAssistClient::new();
mock_sync_assist_client
.expect_resolve_rebase_conflicts()
.times(0);
let temp_dir = tempdir().expect("failed to create temporary test directory");
let input = build_sync_rebase_input_for_test(
temp_dir.path().to_path_buf(),
Arc::new(mock_git_client),
Arc::new(mock_sync_assist_client),
);
let conflicted_files = SessionManager::load_sync_conflicted_files(&input, &[]).await;
let files = conflicted_files.expect("load_sync_conflicted_files should succeed");
assert_eq!(
files,
vec![
"src/a.rs".to_string(),
"src/b.rs".to_string(),
"src/c.rs".to_string(),
]
);
}
#[tokio::test]
async fn test_stage_and_check_for_sync_conflicts_detects_remaining_markers() {
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
mock_git_client
.expect_stage_all()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_has_unmerged_paths()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok(false) }));
mock_git_client
.expect_list_staged_conflict_marker_files()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok(vec!["src/lib.rs".to_string()]) }));
let mut mock_sync_assist_client = MockSyncAssistClient::new();
mock_sync_assist_client
.expect_resolve_rebase_conflicts()
.times(0);
let temp_dir = tempdir().expect("failed to create temporary test directory");
let input = build_sync_rebase_input_for_test(
temp_dir.path().to_path_buf(),
Arc::new(mock_git_client),
Arc::new(mock_sync_assist_client),
);
let still_has_conflicts =
SessionManager::stage_and_check_for_sync_conflicts(&input, &["src/lib.rs".to_string()])
.await;
let has_conflicts = still_has_conflicts.expect("stage_and_check should succeed");
assert!(has_conflicts);
}
#[tokio::test]
async fn test_run_sync_rebase_assist_loop_aborts_for_unchanged_conflict_files() {
let temp_dir = tempdir().expect("create temp dir");
let conflict_file = temp_dir.path().join("src/lib.rs");
std::fs::create_dir_all(
conflict_file
.parent()
.expect("conflict file should have a parent directory"),
)
.expect("create conflict directory");
std::fs::write(&conflict_file, "<<<<<<< HEAD\none\n=======\ntwo\n>>>>>>>")
.expect("write conflict file");
let fingerprint_fs_client = create_passthrough_mock_fs_client();
let fingerprint = SessionManager::conflicted_file_fingerprint(
&fingerprint_fs_client,
temp_dir.path(),
&["src/lib.rs".to_string()],
)
.await;
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_list_conflicted_files()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_| Box::pin(async { Ok(vec!["src/lib.rs".to_string()]) }));
mock_git_client
.expect_list_staged_conflict_marker_files()
.times(REBASE_ASSIST_POLICY.max_attempts)
.returning(|_, _| Box::pin(async { Ok(vec![]) }));
mock_git_client
.expect_stage_all()
.times(REBASE_ASSIST_POLICY.max_attempts - 1)
.returning(|_| Box::pin(async { Ok(()) }));
mock_git_client
.expect_has_unmerged_paths()
.times(REBASE_ASSIST_POLICY.max_attempts - 1)
.returning(|_| Box::pin(async { Ok(true) }));
mock_git_client
.expect_abort_rebase()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let mut mock_sync_assist_client = MockSyncAssistClient::new();
mock_sync_assist_client
.expect_resolve_rebase_conflicts()
.times(REBASE_ASSIST_POLICY.max_attempts - 1)
.returning(|_, _, _| Box::pin(async { Ok(()) }));
let input = build_sync_rebase_input_for_test(
temp_dir.path().to_path_buf(),
Arc::new(mock_git_client),
Arc::new(mock_sync_assist_client),
);
let result = SessionManager::run_sync_rebase_assist_loop(input, fingerprint).await;
assert_eq!(
result.map_err(|error| error.to_string()),
Err(
"Sync rebase assistance made no progress: conflicted files did not change"
.to_string()
)
);
}
#[tokio::test]
async fn test_conflicted_file_fingerprint_changes_with_file_content() {
let fs_client = create_passthrough_mock_fs_client();
let temp_dir = std::env::temp_dir().join(format!(
"agentty_fp_content_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
let file_path = temp_dir.join("conflict.rs");
let files = vec!["conflict.rs".to_string()];
std::fs::write(&file_path, "<<<<<<< HEAD\nfoo\n=======\nbar\n>>>>>>>")
.expect("write initial content");
let fingerprint_before =
SessionManager::conflicted_file_fingerprint(&fs_client, &temp_dir, &files).await;
std::fs::write(
&file_path,
"<<<<<<< HEAD\nfoo_patched\n=======\nbar\n>>>>>>>",
)
.expect("write patched content");
let fingerprint_after =
SessionManager::conflicted_file_fingerprint(&fs_client, &temp_dir, &files).await;
assert_ne!(fingerprint_before, fingerprint_after);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_conflicted_file_fingerprint_stable_for_unchanged_content() {
let fs_client = create_passthrough_mock_fs_client();
let temp_dir = std::env::temp_dir().join(format!(
"agentty_fp_stable_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
std::fs::write(temp_dir.join("conflict.rs"), "same content").expect("write file");
let files = vec!["conflict.rs".to_string()];
let fingerprint_a =
SessionManager::conflicted_file_fingerprint(&fs_client, &temp_dir, &files).await;
let fingerprint_b =
SessionManager::conflicted_file_fingerprint(&fs_client, &temp_dir, &files).await;
assert_eq!(fingerprint_a, fingerprint_b);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_conflicted_file_fingerprint_order_independent() {
let fs_client = create_passthrough_mock_fs_client();
let temp_dir = std::env::temp_dir().join(format!(
"agentty_fp_order_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
std::fs::write(temp_dir.join("a.rs"), "content a").expect("write a.rs");
std::fs::write(temp_dir.join("b.rs"), "content b").expect("write b.rs");
let fingerprint_ab = SessionManager::conflicted_file_fingerprint(
&fs_client,
&temp_dir,
&["a.rs".to_string(), "b.rs".to_string()],
)
.await;
let fingerprint_ba = SessionManager::conflicted_file_fingerprint(
&fs_client,
&temp_dir,
&["b.rs".to_string(), "a.rs".to_string()],
)
.await;
assert_eq!(fingerprint_ab, fingerprint_ba);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_conflicted_file_fingerprint_missing_file_is_stable() {
let fs_client = create_passthrough_mock_fs_client();
let temp_dir = std::env::temp_dir().join(format!(
"agentty_fp_missing_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
let files = vec!["nonexistent.rs".to_string()];
let fingerprint_a =
SessionManager::conflicted_file_fingerprint(&fs_client, &temp_dir, &files).await;
let fingerprint_b =
SessionManager::conflicted_file_fingerprint(&fs_client, &temp_dir, &files).await;
assert_eq!(fingerprint_a, fingerprint_b);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}