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 ag_agent::{self as agent, OneShotClient};
use ag_forge as forge;
use ag_git::{self as git, GitClient};
use ag_protocol::AgentResponseSummary;
use askama::Template;
use tokio::sync::mpsc;
use tracing::warn;
use super::published_branch::{self, PublishedBranchAutoPushInput};
use super::worker::{SessionCommand, has_unfinished_rebase_operation};
use super::{SessionTaskService, StatusTransition, session_branch};
use crate::app::assist::{
AssistContext, AssistPolicy, FailureTracker, append_assist_header, format_detail_lines,
run_agent_assist,
};
use crate::app::service::SessionUpdateVersionMap;
use crate::app::session::{Clock, SessionError};
use crate::app::{AppEvent, AppServices, ProjectManager, SessionManager};
use crate::domain::agent::{AgentKind, AgentModel, AgentSelection, ReasoningLevel};
use crate::domain::session::{PublishedBranchSyncStatus, SessionId, Status};
use crate::domain::session_message::SessionTranscript;
use crate::domain::transcript_notice::TranscriptNotice;
use crate::infra::db::{AppRepositories, DbError};
use crate::infra::fs::{self as fs, FsClient};
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,
workspace_note: &'a str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RebaseAssistWorkspace {
SessionWorktree,
MainCheckout,
}
impl RebaseAssistWorkspace {
fn note(self) -> &'static str {
match self {
Self::SessionWorktree => {
"You are working inside the session's isolated git worktree; keep every change \
inside this worktree and never touch the repository's main checkout."
}
Self::MainCheckout => {
"You are working inside the user's main repository checkout for a user-approved \
sync; change only the conflicted files listed below."
}
}
}
}
type SyncAssistFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
pub(super) type RebaseAssistFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
pub(super) trait ExistingSessionRebaseAssistClient: Send + Sync {
fn resolve_rebase_conflicts(
&self,
prompt: String,
) -> RebaseAssistFuture<Result<(), SessionError>>;
}
#[derive(Clone)]
pub(super) enum RebaseAssistMode {
ExistingSession(Arc<dyn ExistingSessionRebaseAssistClient>),
OneShot,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum RebasePlan {
Target {
target: String,
},
Onto {
new_base: String,
old_base: String,
},
}
impl RebasePlan {
fn target(target: String) -> Self {
Self::Target { target }
}
fn target_label(&self) -> &str {
match self {
Self::Target { target } => target,
Self::Onto { new_base, .. } => new_base,
}
}
fn old_base(&self) -> Option<&str> {
match self {
Self::Target { .. } => None,
Self::Onto { old_base, .. } => Some(old_base),
}
}
}
struct MergeStartRestoreContext<'a> {
db: &'a AppRepositories,
session_id: &'a str,
status_transition: &'a StatusTransition,
}
struct MergeTaskInput {
app_event_tx: mpsc::UnboundedSender<AppEvent>,
archive_diff: bool,
base_branch: String,
child_pid: Arc<Mutex<Option<u32>>>,
clock: Arc<dyn Clock>,
db: AppRepositories,
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
id: SessionId,
one_shot_client: Arc<dyn OneShotClient>,
repo_root: PathBuf,
session_agent: AgentSelection,
session_update_versions: SessionUpdateVersionMap,
source_branch: String,
status: Arc<Mutex<Status>>,
transcript: Arc<Mutex<SessionTranscript>>,
}
struct SuccessfulMergeCompletion<'a> {
app_event_tx: &'a mpsc::UnboundedSender<AppEvent>,
authoritative_commit_message: Option<&'a str>,
base_branch: &'a str,
db: &'a AppRepositories,
id: &'a SessionId,
merged_commit_hash: Option<&'a str>,
parent_commit_hash: String,
status_transition: &'a StatusTransition,
}
#[derive(Clone)]
struct RebaseAssistInput {
app_event_tx: mpsc::UnboundedSender<AppEvent>,
assist_mode: RebaseAssistMode,
child_pid: Arc<Mutex<Option<u32>>>,
db: AppRepositories,
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
id: SessionId,
one_shot_client: Arc<dyn OneShotClient>,
rebase_plan: RebasePlan,
session_agent: AgentSelection,
session_update_versions: SessionUpdateVersionMap,
transcript: Arc<Mutex<SessionTranscript>>,
}
pub(super) struct RebaseCommandInput {
pub(super) app_event_tx: mpsc::UnboundedSender<AppEvent>,
pub(super) assist_mode: RebaseAssistMode,
pub(super) base_branch: String,
pub(super) branch_operation_lock: Arc<tokio::sync::Mutex<()>>,
pub(super) child_pid: Arc<Mutex<Option<u32>>>,
pub(super) clock: Arc<dyn Clock>,
pub(super) db: AppRepositories,
pub(super) folder: PathBuf,
pub(super) fs_client: Arc<dyn FsClient>,
pub(super) git_client: Arc<dyn GitClient>,
pub(super) id: SessionId,
pub(super) one_shot_client: Arc<dyn OneShotClient>,
pub(super) review_request_client: Arc<dyn forge::ReviewRequestClient>,
pub(super) session_agent: AgentSelection,
pub(super) session_update_versions: SessionUpdateVersionMap,
pub(super) status: Arc<Mutex<Status>>,
pub(super) transcript: Arc<Mutex<SessionTranscript>>,
}
struct FinalizeRebaseInput<'a> {
app_event_tx: &'a mpsc::UnboundedSender<AppEvent>,
branch_operation_lock: &'a Arc<tokio::sync::Mutex<()>>,
clock: &'a Arc<dyn Clock>,
db: &'a AppRepositories,
folder: &'a Path,
git_client: &'a Arc<dyn GitClient>,
id: &'a str,
one_shot_client: &'a Arc<dyn OneShotClient>,
rebase_result: Result<String, SessionError>,
review_request_client: &'a Arc<dyn forge::ReviewRequestClient>,
session_agent: AgentSelection,
session_update_versions: &'a SessionUpdateVersionMap,
status_transition: &'a StatusTransition,
transcript: &'a Arc<Mutex<SessionTranscript>>,
}
struct RebaseAutoPushInput<'a> {
app_event_tx: &'a mpsc::UnboundedSender<AppEvent>,
branch_operation_lock: &'a Arc<tokio::sync::Mutex<()>>,
clock: &'a Arc<dyn Clock>,
db: &'a AppRepositories,
folder: &'a Path,
git_client: &'a Arc<dyn GitClient>,
one_shot_client: &'a Arc<dyn OneShotClient>,
review_request_client: &'a Arc<dyn forge::ReviewRequestClient>,
session_agent: AgentSelection,
session_id: &'a str,
session_update_versions: &'a SessionUpdateVersionMap,
transcript: &'a Arc<Mutex<SessionTranscript>>,
}
struct FinalizeMergeInput<'a> {
app_event_tx: &'a mpsc::UnboundedSender<AppEvent>,
db: &'a AppRepositories,
id: &'a str,
result: Result<String, SessionError>,
session_update_versions: &'a SessionUpdateVersionMap,
status_transition: &'a StatusTransition,
transcript: &'a Arc<Mutex<SessionTranscript>>,
}
struct SyncRebaseAssistInput {
app_event_tx: Option<mpsc::UnboundedSender<AppEvent>>,
base_branch: String,
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
session_agent: AgentSelection,
sync_assist_client: Arc<dyn SyncAssistClient>,
}
enum RebaseAssistLoopInput {
Session(Box<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 git_client(&self) -> &dyn GitClient {
match self {
Self::Session(input) => input.git_client.as_ref(),
Self::Project(input) => input.git_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> {
SessionManager::load_rebase_conflicted_files(
self.git_client(),
self.folder(),
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::emit_sync_rebase_conflict_status(input, conflicted_files);
SessionManager::run_sync_rebase_assist_agent(input, conflicted_files).await
}
}
}
async fn stage_and_check_for_conflicts(
&self,
conflict_files: &[String],
) -> Result<bool, SessionError> {
SessionManager::stage_and_check_rebase_conflicts(
self.git_client(),
self.folder(),
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_agent: AgentSelection,
) -> SyncAssistFuture<Result<(), SessionError>>;
}
struct RealSyncAssistClient {
one_shot_client: Arc<dyn OneShotClient>,
}
impl RealSyncAssistClient {
fn new() -> Self {
Self {
one_shot_client: Arc::new(agent::RealOneShotClient::new(None)),
}
}
async fn run_assist_command(
one_shot_client: &dyn OneShotClient,
folder: PathBuf,
prompt: String,
session_agent: AgentSelection,
) -> Result<(), SessionError> {
let _ = one_shot_client
.submit(agent::OneShotRequest {
agent_kind: session_agent.kind(),
child_pid: None,
folder,
model: session_agent.model(),
prompt,
request_kind: ag_agent::AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
})
.await?;
Ok(())
}
}
impl SyncAssistClient for RealSyncAssistClient {
fn resolve_rebase_conflicts(
&self,
folder: PathBuf,
prompt: String,
session_agent: AgentSelection,
) -> SyncAssistFuture<Result<(), SessionError>> {
let one_shot_client = Arc::clone(&self.one_shot_client);
Box::pin(async move {
Self::run_assist_command(one_shot_client.as_ref(), folder, prompt, session_agent).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) default_branch: String,
pub(crate) deferred_merged_session_ids: Vec<SessionId>,
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();
}
}
enum RebaseAssistAttemptOutcome {
Continue,
Completed,
}
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.owns_branch_changes() {
return Err(SessionError::Workflow(
"Orchestrator sessions do not own branch changes".to_string(),
));
}
if !(session.status.allows_review_actions() || session.status == Status::Queued) {
return Err(SessionError::Workflow(
"Session must be in review or queued status".to_string(),
));
}
if !manager.can_merge_session_branch_in_stack(session_id) {
return Err(SessionError::Workflow(
"Merge cannot run for linked review requests or while another stack session is \
active"
.to_string(),
));
}
let (archive_diff, db, folder, id, session_agent) = (
session.is_managed(),
services.db().clone(),
session.folder.clone(),
session.id.clone(),
session.agent,
);
let (app_event_tx, clock, fs_client, git_client, session_update_versions) = (
services.event_sender(),
services.clock(),
services.fs_client(),
manager.git_client(),
services.session_update_versions(),
);
let handles = manager
.session_handles_or_err(session_id)
.map_err(|_| SessionError::HandlesNotFound)?;
let (child_pid, status, transcript) = (
Arc::clone(&handles.child_pid),
Arc::clone(&handles.status),
Arc::clone(&handles.transcript),
);
let status_transition = StatusTransition::from_parts(
app_event_tx.clone(),
Arc::clone(&clock),
db.clone(),
id.clone(),
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
let restore_context = MergeStartRestoreContext {
db: &db,
session_id: &id,
status_transition: &status_transition,
};
status_transition
.apply_or_invalid_transition(Status::Merging)
.await?;
let base_branch = Self::load_merge_base_branch(&restore_context).await?;
let repo_root = Self::find_merge_repo_root(
git_client.as_ref(),
projects.working_dir().to_path_buf(),
&restore_context,
)
.await?;
Self::ensure_merge_target_clean(
git_client.as_ref(),
repo_root.clone(),
&base_branch,
&restore_context,
)
.await?;
let merge_task_input = MergeTaskInput {
app_event_tx,
archive_diff,
base_branch,
child_pid,
clock,
db,
folder,
fs_client,
git_client,
id: id.clone(),
one_shot_client: services.one_shot_client(),
repo_root,
session_agent,
session_update_versions,
source_branch: session_branch(&id),
status,
transcript,
};
tokio::spawn(async move {
SessionManager::run_merge_task(merge_task_input).await;
});
Ok(())
}
async fn restore_review_status(context: &MergeStartRestoreContext<'_>) {
if !context.status_transition.apply(Status::Review).await {
warn!(
session_id = context.session_id,
"skipped restoring review status because the in-memory status was already current"
);
}
}
async fn load_merge_base_branch(
context: &MergeStartRestoreContext<'_>,
) -> Result<String, SessionError> {
match context
.db
.sessions()
.get_session_base_branch(context.session_id)
.await
{
Ok(Some(base_branch)) => Ok(base_branch),
Ok(None) => {
Self::restore_review_status(context).await;
Err(SessionError::Workflow(
"No git worktree for this session".to_string(),
))
}
Err(error) => {
Self::restore_review_status(context).await;
Err(SessionError::Db(error))
}
}
}
async fn find_merge_repo_root(
git_client: &dyn GitClient,
working_dir: PathBuf,
context: &MergeStartRestoreContext<'_>,
) -> Result<PathBuf, SessionError> {
if let Some(repo_root) = git_client.find_git_repo_root(working_dir).await {
return Ok(repo_root);
}
Self::restore_review_status(context).await;
Err(SessionError::Workflow(
"Failed to find git repository root".to_string(),
))
}
async fn ensure_merge_target_clean(
git_client: &dyn GitClient,
repo_root: PathBuf,
base_branch: &str,
context: &MergeStartRestoreContext<'_>,
) -> Result<(), SessionError> {
let is_clean = match git_client.is_worktree_clean(repo_root).await {
Ok(is_clean) => is_clean,
Err(error) => {
Self::restore_review_status(context).await;
return Err(SessionError::Workflow(format!(
"Failed to inspect `{base_branch}` before merge: {error}"
)));
}
};
if is_clean {
return Ok(());
}
Self::restore_review_status(context).await;
Err(SessionError::Workflow(format!(
"Merge cannot run while `{base_branch}` has uncommitted changes.\nCommit or stash \
changes in `{base_branch}`, then try again."
)))
}
async fn rebase_session(
&self,
manager: &mut SessionManager,
services: &AppServices,
session_id: &str,
) -> Result<(), SessionError> {
let (base_branch, current_status, persisted_session_id) =
Self::load_rebase_start_context(manager, services, session_id).await?;
let branch_operation_lock = manager
.session_handles_or_err(session_id)
.map(|handles| Arc::clone(&handles.branch_operation_lock))
.map_err(|_| SessionError::HandlesNotFound)?;
let _branch_operation_guard = branch_operation_lock.lock_owned().await;
let command = SessionCommand::Rebase {
base_branch,
operation_id: uuid::Uuid::new_v4().to_string(),
};
if current_status == Status::InProgress {
let unfinished_operations = services
.db()
.operations()
.load_unfinished_session_operations()
.await?;
if has_unfinished_rebase_operation(
&unfinished_operations,
persisted_session_id.as_str(),
) {
return Ok(());
}
manager
.worker_service_mut()
.enqueue_existing_session_command(services, &persisted_session_id, command)
.await?;
SessionTaskService::emit_session_workflow_notice(
&services.event_sender(),
persisted_session_id.as_str(),
TranscriptNotice::Rebase.format(
"Queued sync; this session will rebase after the current turn finishes.",
),
);
} else {
let handles = manager
.session_handles_or_err(session_id)
.map_err(|_| SessionError::HandlesNotFound)?;
let status_transition =
StatusTransition::from_services(services, handles, persisted_session_id.clone());
status_transition
.apply_or_invalid_transition(Status::Rebasing)
.await?;
Self::enqueue_with_status_rollback(
manager,
services,
&status_transition,
&persisted_session_id,
command,
current_status,
)
.await?;
}
Ok(())
}
async fn rebase_stacked_children_after_parent_turn(
&self,
manager: &mut SessionManager,
services: &AppServices,
parent_session_id: &str,
) -> Vec<(SessionId, SessionError)> {
let child_session_ids =
Self::rebaseable_stacked_child_session_ids(manager, parent_session_id);
let mut failures = Vec::new();
for child_session_id in child_session_ids {
if let Err(error) = Self::enqueue_stacked_child_auto_rebase(
manager,
services,
child_session_id.as_str(),
)
.await
{
failures.push((child_session_id, error));
}
}
failures
}
async fn rebase_sessions_after_parent_merge(
&self,
manager: &mut SessionManager,
services: &AppServices,
child_session_ids: Vec<SessionId>,
) -> Vec<(SessionId, SessionError)> {
let mut failures = Vec::new();
for child_session_id in child_session_ids {
let should_rebase_child = manager
.sessions()
.iter()
.find(|session| session.id == child_session_id)
.is_some_and(|session| session.status.allows_review_actions());
if !should_rebase_child {
continue;
}
if let Err(error) = Self::enqueue_stacked_child_auto_rebase(
manager,
services,
child_session_id.as_str(),
)
.await
{
failures.push((child_session_id, error));
}
}
failures
}
fn rebaseable_stacked_child_session_ids(
manager: &SessionManager,
parent_session_id: &str,
) -> Vec<SessionId> {
let Some(parent_session) = manager
.sessions()
.iter()
.find(|session| session.id.as_str() == parent_session_id)
else {
return Vec::new();
};
if !parent_session.status.allows_review_actions() {
return Vec::new();
}
manager
.sessions()
.iter()
.filter(|session| {
session
.parent_session_id
.as_ref()
.is_some_and(|parent_id| parent_id.as_str() == parent_session_id)
&& session.status.allows_review_actions()
})
.map(|session| session.id.clone())
.collect()
}
async fn enqueue_stacked_child_auto_rebase(
manager: &mut SessionManager,
services: &AppServices,
child_session_id: &str,
) -> Result<(), SessionError> {
let (base_branch, previous_status, persisted_session_id) = {
let session = manager
.session_or_err(child_session_id)
.map_err(|_| SessionError::NotFound)?;
let base_branch = services
.db()
.sessions()
.get_session_base_branch(&session.id)
.await?
.ok_or_else(|| {
SessionError::Workflow("No git worktree for this session".to_string())
})?;
(base_branch, session.status, session.id.clone())
};
let handles = manager
.session_handles_or_err(child_session_id)
.map_err(|_| SessionError::HandlesNotFound)?;
let status_transition =
StatusTransition::from_services(services, handles, persisted_session_id.clone());
status_transition
.apply_or_invalid_transition(Status::Rebasing)
.await?;
let command = SessionCommand::Rebase {
base_branch,
operation_id: uuid::Uuid::new_v4().to_string(),
};
Self::enqueue_with_status_rollback(
manager,
services,
&status_transition,
&persisted_session_id,
command,
previous_status,
)
.await?;
Ok(())
}
async fn enqueue_with_status_rollback(
manager: &mut SessionManager,
services: &AppServices,
status_transition: &StatusTransition,
session_id: &SessionId,
command: SessionCommand,
fallback_status: Status,
) -> Result<(), SessionError> {
if let Err(error) = manager
.enqueue_session_command(services, session_id, command)
.await
{
let _ = status_transition.apply(fallback_status).await;
return Err(error);
}
Ok(())
}
async fn load_rebase_start_context(
manager: &SessionManager,
services: &AppServices,
session_id: &str,
) -> Result<(String, Status, SessionId), SessionError> {
let session = manager
.session_or_err(session_id)
.map_err(|_| SessionError::NotFound)?;
if !session.status.allows_review_actions() && session.status != Status::InProgress {
return Err(SessionError::Workflow(
"Session must be in review status or in progress".to_string(),
));
}
if !manager.can_rebase_session_branch_in_stack(session_id) {
return Err(SessionError::Workflow(
"Stacked sync can only run when no other stack session is active".to_string(),
));
}
let base_branch = services
.db()
.sessions()
.get_session_base_branch(&session.id)
.await?
.ok_or_else(|| {
SessionError::Workflow("No git worktree for this session".to_string())
})?;
Ok((base_branch, session.status, session.id.clone()))
}
}
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 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 session_update_versions = input.session_update_versions.clone();
let transcript = Arc::clone(&input.transcript);
let merge_result = Self::execute_merge_workflow(input).await;
let status_transition = StatusTransition::from_parts(
app_event_tx.clone(),
Arc::clone(&clock),
db.clone(),
id.clone(),
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
Self::finalize_merge_task(FinalizeMergeInput {
app_event_tx: &app_event_tx,
db: &db,
id: &id,
result: merge_result,
session_update_versions: &session_update_versions,
status_transition: &status_transition,
transcript: &transcript,
})
.await;
}
async fn execute_merge_workflow(input: MergeTaskInput) -> Result<String, SessionError> {
let rebase_input = Self::merge_rebase_input(&input);
let MergeTaskInput {
app_event_tx,
archive_diff,
base_branch,
clock,
db,
folder,
fs_client,
git_client,
id,
repo_root,
source_branch,
session_update_versions,
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 parent_commit_hash = git_client.head_hash(folder.clone()).await?;
let squash_diff = Self::load_squash_diff(
git_client.as_ref(),
repo_root.clone(),
source_branch.clone(),
base_branch.clone(),
)
.await?;
if archive_diff {
db.sessions()
.update_session_archived_diff(&id, Some(squash_diff.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
};
let merged_commit_hash =
Self::load_merged_commit_hash(git_client.as_ref(), repo_root.clone(), merge_outcome)
.await?;
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}"
))
})?;
let status_transition = StatusTransition::from_parts(
app_event_tx.clone(),
Arc::clone(&clock),
db.clone(),
id.clone(),
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
Self::complete_successful_merge(SuccessfulMergeCompletion {
app_event_tx: &app_event_tx,
authoritative_commit_message: authoritative_commit_message.as_deref(),
base_branch: &base_branch,
db: &db,
id: &id,
merged_commit_hash: merged_commit_hash.as_deref(),
parent_commit_hash,
status_transition: &status_transition,
})
.await?;
Ok(Self::merge_success_message(
&source_branch,
&base_branch,
merge_outcome,
))
}
async fn complete_successful_merge(
input: SuccessfulMergeCompletion<'_>,
) -> Result<(), SessionError> {
Self::persist_merged_session_metadata(
input.db,
input.id,
input.authoritative_commit_message,
input.merged_commit_hash,
input.app_event_tx,
)
.await?;
let restacked_child_session_ids = Self::restack_child_sessions_after_parent_merge(
input.db,
input.id,
input.base_branch,
Some(input.parent_commit_hash),
)
.await?;
Self::emit_stacked_parent_merge_completed(input.app_event_tx, restacked_child_session_ids);
input
.status_transition
.apply_or_invalid_transition(Status::Done)
.await
}
pub(crate) async fn restack_child_sessions_after_parent_merge(
db: &AppRepositories,
parent_session_id: &str,
base_branch: &str,
parent_commit_hash: Option<String>,
) -> Result<Vec<SessionId>, SessionError> {
let child_session_ids = db
.sessions()
.restack_child_sessions_after_parent_merge(
parent_session_id,
base_branch,
parent_commit_hash,
)
.await?
.into_iter()
.map(SessionId::from)
.collect();
Ok(child_session_ids)
}
pub(crate) fn emit_stacked_parent_merge_completed(
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
child_session_ids: Vec<SessionId>,
) {
if child_session_ids.is_empty() {
return;
}
if app_event_tx
.send(AppEvent::StackedParentMergeCompleted { child_session_ids })
.is_err()
{
warn!(
"failed to enqueue post-merge stacked child restacks because the app event \
receiver is closed"
);
}
}
async fn persist_merged_session_metadata(
db: &AppRepositories,
session_id: &str,
authoritative_commit_message: Option<&str>,
merged_commit_hash: Option<&str>,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
) -> Result<(), SessionError> {
if let Some(commit_message) = authoritative_commit_message {
Self::update_session_title_from_commit_message(
db,
session_id,
commit_message,
app_event_tx,
)
.await;
Self::update_done_session_summary_from_commit_message(db, session_id, commit_message)
.await;
}
if let Some(merged_commit_hash) = merged_commit_hash {
db.sessions()
.update_session_merged_commit_hash(session_id, Some(merged_commit_hash.to_string()))
.await?;
}
Ok(())
}
async fn load_merged_commit_hash(
git_client: &dyn GitClient,
repo_root: PathBuf,
merge_outcome: git::SquashMergeOutcome,
) -> Result<Option<String>, SessionError> {
if merge_outcome != git::SquashMergeOutcome::Committed {
return Ok(None);
}
let merged_commit_hash = git_client.head_hash(repo_root).await?;
Ok(Some(merged_commit_hash))
}
fn merge_rebase_input(input: &MergeTaskInput) -> RebaseAssistInput {
RebaseAssistInput {
app_event_tx: input.app_event_tx.clone(),
assist_mode: RebaseAssistMode::OneShot,
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(),
one_shot_client: Arc::clone(&input.one_shot_client),
rebase_plan: RebasePlan::target(input.base_branch.clone()),
session_agent: input.session_agent,
session_update_versions: input.session_update_versions.clone(),
transcript: Arc::clone(&input.transcript),
}
}
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(input: FinalizeMergeInput<'_>) {
let FinalizeMergeInput {
app_event_tx,
db,
id,
result,
session_update_versions,
status_transition,
transcript,
} = input;
match result {
Ok(message) => {
let merge_message = TranscriptNotice::Merge.format_line(message);
SessionTaskService::emit_session_workflow_notice(app_event_tx, id, merge_message);
SessionTaskService::request_git_status_refresh(app_event_tx);
}
Err(error) => {
let merge_error = TranscriptNotice::MergeError.format(error);
SessionTaskService::append_workflow_notice(
transcript,
db,
app_event_tx,
session_update_versions,
id,
&merge_error,
)
.await;
if !status_transition.apply(Status::Review).await {
warn!(
session_id = id,
"skipped restoring review status after merge error because the in-memory \
status was already current"
);
}
}
}
}
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(
&mut self,
services: &AppServices,
session_id: &str,
) -> Result<(), SessionError> {
SessionMergeService
.rebase_session(self, services, session_id)
.await
}
pub(crate) async fn rebase_stacked_children_after_parent_turn(
&mut self,
services: &AppServices,
parent_session_id: &str,
) -> Vec<(SessionId, SessionError)> {
SessionMergeService
.rebase_stacked_children_after_parent_turn(self, services, parent_session_id)
.await
}
pub(crate) async fn rebase_sessions_after_parent_merge(
&mut self,
services: &AppServices,
child_session_ids: Vec<SessionId>,
) -> Vec<(SessionId, SessionError)> {
SessionMergeService
.rebase_sessions_after_parent_merge(self, services, child_session_ids)
.await
}
pub(crate) async fn rebase_pending_stack_restacks_for_project(
&mut self,
services: &AppServices,
project_id: i64,
) -> Vec<(SessionId, SessionError)> {
let child_session_ids = services
.db()
.sessions()
.load_pending_stack_restack_session_ids(project_id)
.await
.unwrap_or_default()
.into_iter()
.map(SessionId::from)
.collect();
self.rebase_sessions_after_parent_merge(services, child_session_ids)
.await
}
pub(crate) async fn sync_main_for_project(
default_branch: Option<String>,
working_dir: PathBuf,
app_event_tx: Option<mpsc::UnboundedSender<AppEvent>>,
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::new());
let session_agent = crate::domain::agent::resolve_agent_selection_for_model(
session_model,
AgentKind::Antigravity,
AgentKind::ALL,
);
Self::sync_main_for_project_with_assist_client(
default_branch,
working_dir,
app_event_tx,
fs_client,
git_client,
session_agent,
sync_assist_client,
)
.await
}
async fn sync_main_for_project_with_assist_client(
default_branch: Option<String>,
working_dir: PathBuf,
app_event_tx: Option<mpsc::UnboundedSender<AppEvent>>,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
session_agent: AgentSelection,
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 {
app_event_tx,
base_branch: default_branch.clone(),
folder: working_dir.clone(),
fs_client: Arc::clone(&fs_client),
git_client: Arc::clone(&git_client),
session_agent,
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 {
default_branch,
deferred_merged_session_ids: Vec::new(),
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 emit_sync_rebase_conflict_status(
input: &SyncRebaseAssistInput,
conflicted_files: &[String],
) {
let Some(app_event_tx) = &input.app_event_tx else {
return;
};
let _ = app_event_tx.send(AppEvent::SyncMainConflictResolutionStarted {
conflicted_files: conflicted_files.to_vec(),
});
}
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 run_sync_rebase_assist_agent(
input: &SyncRebaseAssistInput,
conflicted_files: &[String],
) -> Result<(), SessionError> {
let prompt = Self::rebase_assist_prompt(
&input.base_branch,
conflicted_files,
RebaseAssistWorkspace::MainCheckout,
)?;
input
.sync_assist_client
.resolve_rebase_conflicts(input.folder.clone(), prompt, input.session_agent)
.await
.map_err(|error| error.with_context("Sync rebase assistance failed"))
}
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();
if let Err(error) = input.git_client.abort_rebase(folder).await {
warn!(
base_branch = input.base_branch,
error = %error,
"failed to abort sync rebase after assistance failure"
);
}
}
pub(super) async fn run_rebase_command(input: RebaseCommandInput) -> Result<(), SessionError> {
let RebaseCommandInput {
app_event_tx,
assist_mode,
base_branch,
branch_operation_lock,
child_pid,
clock,
db,
folder,
fs_client,
git_client,
id,
one_shot_client,
review_request_client,
session_agent,
session_update_versions,
status,
transcript,
} = input;
let status_transition = StatusTransition::from_parts(
app_event_tx.clone(),
Arc::clone(&clock),
db.clone(),
id.clone(),
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
status_transition
.apply_or_invalid_transition(Status::Rebasing)
.await?;
let rebase_result: Result<String, SessionError> = async {
let rebase_plan = Self::resolve_session_rebase_plan(
&db,
git_client.as_ref(),
&folder,
&id,
&base_branch,
)
.await?;
let rebase_input = RebaseAssistInput {
app_event_tx: app_event_tx.clone(),
assist_mode,
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(),
one_shot_client: Arc::clone(&one_shot_client),
rebase_plan,
session_agent,
session_update_versions: session_update_versions.clone(),
transcript: Arc::clone(&transcript),
};
Self::execute_rebase_workflow(rebase_input).await
}
.await;
let command_error = rebase_result.as_ref().err().map(ToString::to_string);
Self::finalize_rebase_task(FinalizeRebaseInput {
app_event_tx: &app_event_tx,
branch_operation_lock: &branch_operation_lock,
clock: &clock,
db: &db,
folder: &folder,
git_client: &git_client,
id: &id,
one_shot_client: &one_shot_client,
rebase_result,
review_request_client: &review_request_client,
session_agent,
session_update_versions: &session_update_versions,
status_transition: &status_transition,
transcript: &transcript,
})
.await;
if let Some(error) = command_error {
return Err(SessionError::Workflow(error));
}
Ok(())
}
async fn resolve_session_rebase_plan(
db: &AppRepositories,
git_client: &dyn GitClient,
folder: &Path,
session_id: &str,
base_branch: &str,
) -> Result<RebasePlan, SessionError> {
let rebase_target =
Self::resolve_session_rebase_target(db, git_client, folder, session_id, base_branch)
.await?;
let stack_base_commit_hash = db
.sessions()
.get_session_stack_base_commit_hash(session_id)
.await
.map_err(SessionError::Db)?;
Ok(match stack_base_commit_hash {
Some(old_base) => RebasePlan::Onto {
new_base: rebase_target,
old_base,
},
None => RebasePlan::target(rebase_target),
})
}
async fn resolve_session_rebase_target(
db: &AppRepositories,
git_client: &dyn GitClient,
folder: &Path,
session_id: &str,
base_branch: &str,
) -> Result<String, SessionError> {
let Some(published_upstream_ref) = db
.sessions()
.load_session_published_upstream_ref(session_id)
.await
.map_err(SessionError::Db)?
else {
return Ok(base_branch.to_string());
};
let Some((remote_name, _branch_name)) = published_upstream_ref.split_once('/') else {
return Ok(base_branch.to_string());
};
git_client
.fetch_remote(folder.to_path_buf())
.await
.map_err(|error| {
SessionError::Workflow(format!(
"Failed to fetch `{remote_name}` before rebasing published session branch: \
{error}"
))
})?;
Ok(format!("{remote_name}/{base_branch}"))
}
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_agent = SessionTaskService::load_auto_commit_agent_setting(
&input.db,
&input.id,
input.session_agent,
)
.await;
let auto_commit_reasoning_level =
SessionTaskService::load_auto_commit_reasoning_level(&input.db, &input.id).await;
match SessionTaskService::commit_session_changes(
input.git_client.as_ref(),
&input.folder,
input.rebase_plan.target_label(),
(auto_commit_agent, auto_commit_reasoning_level),
input.one_shot_client.as_ref(),
false,
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 = TranscriptNotice::Commit
.format_line(format!("committed with hash `{}`", outcome.commit_hash));
SessionTaskService::emit_session_workflow_notice(
&input.app_event_tx,
&input.id,
commit_message,
);
SessionTaskService::request_git_status_refresh(&input.app_event_tx);
}
Err(error) if error.to_string().contains("Nothing to commit") => {
let commit_message = TranscriptNotice::Commit.format_line("No changes to commit.");
SessionTaskService::emit_session_workflow_notice(
&input.app_event_tx,
&input.id,
commit_message,
);
}
Err(error) => {
return Err(SessionError::Workflow(format!(
"Failed to commit pending changes before sync: {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 sync: {error}")));
}
Self::persist_stack_base_after_successful_rebase(&input).await?;
let source_branch = session_branch(&input.id);
let rebase_target = input.rebase_plan.target_label();
Ok(format!(
"Successfully synced {source_branch} onto {rebase_target}"
))
}
async fn persist_stack_base_after_successful_rebase(
input: &RebaseAssistInput,
) -> Result<(), SessionError> {
let parent_session_id = input
.db
.sessions()
.get_session_parent_session_id(&input.id)
.await
.map_err(SessionError::Db)?;
if parent_session_id.is_some() {
let target_hash = input
.git_client
.ref_hash(
input.folder.clone(),
input.rebase_plan.target_label().to_string(),
)
.await?;
input
.db
.sessions()
.update_session_stack_base_commit_hash(&input.id, Some(target_hash))
.await
.map_err(SessionError::Db)?;
return Ok(());
}
if input.rebase_plan.old_base().is_some() {
input
.db
.sessions()
.update_session_stack_base_commit_hash(&input.id, None)
.await
.map_err(SessionError::Db)?;
}
Ok(())
}
async fn finalize_rebase_task(input: FinalizeRebaseInput<'_>) {
let FinalizeRebaseInput {
app_event_tx,
branch_operation_lock,
clock,
db,
folder,
git_client,
id,
one_shot_client,
rebase_result,
review_request_client,
session_agent,
session_update_versions,
status_transition,
transcript,
} = input;
let rebase_succeeded = rebase_result.is_ok();
match rebase_result {
Ok(message) => {
let rebase_message = TranscriptNotice::Rebase.format(message);
SessionTaskService::append_workflow_notice(
transcript,
db,
app_event_tx,
session_update_versions,
id,
&rebase_message,
)
.await;
SessionTaskService::request_git_status_refresh(app_event_tx);
Self::start_auto_push_after_rebase(RebaseAutoPushInput {
app_event_tx,
branch_operation_lock,
clock,
db,
folder,
git_client,
one_shot_client,
review_request_client,
session_agent,
session_id: id,
session_update_versions,
transcript,
})
.await;
}
Err(error) => {
let rebase_error = TranscriptNotice::RebaseError.format(error);
SessionTaskService::append_workflow_notice(
transcript,
db,
app_event_tx,
session_update_versions,
id,
&rebase_error,
)
.await;
}
}
let restored_review_status = status_transition.apply(Status::Review).await;
if !restored_review_status {
warn!(
session_id = id,
"skipped restoring review status after rebase because the in-memory status was \
already current"
);
}
if rebase_succeeded && restored_review_status {
let _ = app_event_tx.send(AppEvent::StackedParentSyncCompleted {
session_id: SessionId::from(id),
});
}
}
async fn start_auto_push_after_rebase(input: RebaseAutoPushInput<'_>) {
let RebaseAutoPushInput {
app_event_tx,
branch_operation_lock,
clock,
db,
folder,
git_client,
one_shot_client,
review_request_client,
session_agent,
session_id,
session_update_versions,
transcript,
} = input;
let branch_operation_guard = Arc::clone(branch_operation_lock).lock_owned().await;
let published_upstream_ref = db
.sessions()
.load_session_published_upstream_ref(session_id)
.await
.ok()
.flatten();
let Some(published_upstream_ref) = published_upstream_ref else {
return;
};
let sync_operation_id = uuid::Uuid::new_v4().to_string();
if app_event_tx
.send(AppEvent::PublishedBranchSyncUpdated {
persistent_notice: None,
session_id: SessionId::from(session_id),
sync_operation_id: sync_operation_id.clone(),
sync_status: PublishedBranchSyncStatus::InProgress,
})
.is_err()
{
warn!(
session_id = session_id,
sync_operation_id = sync_operation_id,
"failed to publish branch sync start because the app event receiver is closed"
);
}
let session_summary = Self::review_request_session_summary_after_rebase(
session_id,
db.sessions().load_session_summary(session_id).await,
);
let review_request_metadata_sync = session_summary.map(|session_summary| {
published_branch::ReviewRequestMetadataSyncInput {
clock: Arc::clone(clock),
commit_message: None,
evaluation: published_branch::ReviewRequestMetadataEvaluationInput {
one_shot_client: Arc::clone(one_shot_client),
session_agent,
session_summary,
},
review_request_client: Arc::clone(review_request_client),
}
});
let app_event_tx = app_event_tx.clone();
let db = db.clone();
let folder = folder.to_path_buf();
let git_client = Arc::clone(git_client);
let transcript = Arc::clone(transcript);
let session_id = SessionId::from(session_id);
let auto_push_input = PublishedBranchAutoPushInput {
app_event_tx,
db,
folder,
git_client,
published_upstream_ref,
review_comment_resolution: None,
review_request_metadata_sync,
session_id,
session_update_versions: session_update_versions.clone(),
sync_operation_id,
transcript,
};
tokio::spawn(async move {
let _branch_operation_guard = branch_operation_guard;
published_branch::run_published_branch_auto_push(auto_push_input).await;
});
}
fn review_request_session_summary_after_rebase(
session_id: &str,
session_summary: Result<Option<String>, DbError>,
) -> Option<String> {
match session_summary {
Ok(session_summary) => Some(session_summary.unwrap_or_default()),
Err(error) => {
warn!(
session_id = session_id,
error = %error,
"failed to load session summary for post-rebase review-request metadata sync"
);
None
}
}
}
pub(crate) async fn update_session_title_from_commit_message(
db: &AppRepositories,
session_id: &str,
commit_message: &str,
app_event_tx: &mpsc::UnboundedSender<AppEvent>,
) {
let title = Self::session_title_from_commit_message(commit_message);
if let Err(error) = db.sessions().update_session_title(session_id, &title).await {
warn!(
session_id = session_id,
error = %error,
"failed to persist session title from commit message"
);
}
if app_event_tx.send(AppEvent::RefreshSessions).is_err() {
warn!(
session_id = session_id,
"failed to refresh sessions after commit-title update because the app event \
receiver is closed"
);
}
}
async fn update_done_session_summary_from_commit_message(
db: &AppRepositories,
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,
);
if let Err(error) = db
.sessions()
.update_session_summary(session_id, &summary)
.await
{
warn!(
session_id = session_id,
error = %error,
"failed to persist done-session summary from commit message"
);
}
}
async fn persisted_session_summary(db: &AppRepositories, session_id: &str) -> Option<String> {
db.sessions()
.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?;
let mut initial_conflict_detail = None;
if !rebase_in_progress {
let initial_step = Self::run_rebase_start(&input).await?;
match initial_step {
git::RebaseStepResult::Completed => return Ok(()),
git::RebaseStepResult::Conflict { detail } => {
initial_conflict_detail = Some(detail);
}
}
}
Self::run_rebase_assist_loop_core(
RebaseAssistLoopInput::Session(Box::new(input)),
initial_conflict_detail,
)
.await
.map(|_| ())
}
async fn run_rebase_assist_loop_core(
assist_input: RebaseAssistLoopInput,
initial_conflict_detail: Option<String>,
) -> Result<RebaseAssistOutcome, SessionError> {
let assist_result =
Self::run_rebase_assist_attempts(&assist_input, initial_conflict_detail).await;
match assist_result {
Ok(assist_outcome) => Ok(assist_outcome),
Err(error) => {
assist_input.abort_rebase_after_assist_failure().await;
Err(error)
}
}
}
async fn run_rebase_assist_attempts(
assist_input: &RebaseAssistLoopInput,
initial_conflict_detail: Option<String>,
) -> Result<RebaseAssistOutcome, SessionError> {
let mut failure_tracker =
Self::rebase_failure_tracker_with_initial_detail(initial_conflict_detail);
let mut assist_outcome = RebaseAssistOutcome::empty();
let mut previous_conflict_files: Vec<String> = vec![];
for assist_attempt in 1..=REBASE_ASSIST_POLICY.max_attempts {
let attempt_outcome = Self::run_rebase_assist_attempt(
assist_input,
&mut failure_tracker,
&mut assist_outcome,
&mut previous_conflict_files,
assist_attempt,
)
.await?;
if let RebaseAssistAttemptOutcome::Completed = attempt_outcome {
return Ok(assist_outcome);
}
}
Err(SessionError::Workflow(assist_input.exhausted_error()))
}
fn rebase_failure_tracker_with_initial_detail(
initial_conflict_detail: Option<String>,
) -> FailureTracker {
let mut failure_tracker =
FailureTracker::new(REBASE_ASSIST_POLICY.max_identical_failure_streak);
if let Some(initial_conflict_detail) = initial_conflict_detail {
let _ = failure_tracker.observe(&initial_conflict_detail);
}
failure_tracker
}
async fn run_rebase_assist_attempt(
assist_input: &RebaseAssistLoopInput,
failure_tracker: &mut FailureTracker,
assist_outcome: &mut RebaseAssistOutcome,
previous_conflict_files: &mut Vec<String>,
assist_attempt: usize,
) -> Result<RebaseAssistAttemptOutcome, SessionError> {
let conflicted_files = assist_input
.load_conflicted_files(previous_conflict_files)
.await?;
if conflicted_files.is_empty() {
return Self::continue_rebase_after_assist_attempt(
assist_input,
failure_tracker,
assist_attempt,
)
.await;
}
Self::resolve_conflicted_files_with_assist(
assist_input,
failure_tracker,
assist_outcome,
previous_conflict_files,
assist_attempt,
conflicted_files,
)
.await
}
async fn resolve_conflicted_files_with_assist(
assist_input: &RebaseAssistLoopInput,
failure_tracker: &mut FailureTracker,
assist_outcome: &mut RebaseAssistOutcome,
previous_conflict_files: &mut Vec<String>,
assist_attempt: usize,
conflicted_files: Vec<String>,
) -> Result<RebaseAssistAttemptOutcome, SessionError> {
Self::record_conflicted_file_fingerprint(assist_input, failure_tracker, &conflicted_files)
.await?;
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 {
return Self::evaluate_remaining_assist_conflicts(assist_attempt);
}
Self::continue_rebase_after_assist_attempt(assist_input, failure_tracker, assist_attempt)
.await
}
async fn record_conflicted_file_fingerprint(
assist_input: &RebaseAssistLoopInput,
failure_tracker: &mut FailureTracker,
conflicted_files: &[String],
) -> Result<(), SessionError> {
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(),
));
}
Ok(())
}
fn evaluate_remaining_assist_conflicts(
assist_attempt: usize,
) -> Result<RebaseAssistAttemptOutcome, SessionError> {
if assist_attempt == REBASE_ASSIST_POLICY.max_attempts {
return Err(SessionError::Workflow(
"Conflicts remain unresolved after maximum assistance attempts".to_string(),
));
}
Ok(RebaseAssistAttemptOutcome::Continue)
}
async fn continue_rebase_after_assist_attempt(
assist_input: &RebaseAssistLoopInput,
failure_tracker: &mut FailureTracker,
assist_attempt: usize,
) -> Result<RebaseAssistAttemptOutcome, SessionError> {
let continue_step = assist_input.run_rebase_continue().await?;
Self::evaluate_rebase_continue_step(
assist_input,
failure_tracker,
assist_attempt,
continue_step,
)
}
fn evaluate_rebase_continue_step(
assist_input: &RebaseAssistLoopInput,
failure_tracker: &mut FailureTracker,
assist_attempt: usize,
continue_step: git::RebaseStepResult,
) -> Result<RebaseAssistAttemptOutcome, SessionError> {
match continue_step {
git::RebaseStepResult::Completed => Ok(RebaseAssistAttemptOutcome::Completed),
git::RebaseStepResult::Conflict { detail } => {
Self::record_rebase_conflict_detail(
assist_input,
failure_tracker,
assist_attempt,
&detail,
)?;
Ok(RebaseAssistAttemptOutcome::Continue)
}
}
}
fn record_rebase_conflict_detail(
assist_input: &RebaseAssistLoopInput,
failure_tracker: &mut FailureTracker,
assist_attempt: usize,
detail: &str,
) -> Result<(), SessionError> {
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),
));
}
Ok(())
}
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 rebase_plan = input.rebase_plan.clone();
match Self::start_git_rebase(input.git_client.as_ref(), folder.clone(), &rebase_plan).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?;
Self::start_git_rebase(input.git_client.as_ref(), folder, &rebase_plan)
.await
.map_err(SessionError::Git)
}
}
}
async fn start_git_rebase(
git_client: &dyn GitClient,
folder: PathBuf,
rebase_plan: &RebasePlan,
) -> Result<git::RebaseStepResult, git::GitError> {
match rebase_plan {
RebasePlan::Target { target } => git_client.rebase_start(folder, target.clone()).await,
RebasePlan::Onto { new_base, old_base } => {
git_client
.rebase_onto_start(folder, new_base.clone(), old_base.clone())
.await
}
}
}
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_rebase_conflicted_files(
git_client: &dyn GitClient,
folder: &Path,
previous_conflict_files: &[String],
) -> Result<Vec<String>, SessionError> {
let folder = folder.to_path_buf();
let mut conflicted = git_client.list_conflicted_files(folder.clone()).await?;
let staged_with_markers = 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),
TranscriptNotice::RebaseAssist,
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.rebase_plan.target_label(),
conflicted_files,
RebaseAssistWorkspace::SessionWorktree,
)?;
match &input.assist_mode {
RebaseAssistMode::ExistingSession(assist_client) => assist_client
.resolve_rebase_conflicts(prompt)
.await
.map_err(|error| error.with_context("Rebase assistance failed")),
RebaseAssistMode::OneShot => {
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_rebase_conflicts(
git_client: &dyn GitClient,
folder: &Path,
conflict_files: &[String],
) -> Result<bool, SessionError> {
let folder = folder.to_path_buf();
git_client.stage_all(folder.clone()).await?;
if git_client.has_unmerged_paths(folder.clone()).await? {
return Ok(true);
}
let staged_with_markers = 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],
workspace: RebaseAssistWorkspace,
) -> Result<String, SessionError> {
let conflicted_files = Self::format_conflicted_file_list(conflicted_files);
let template = RebaseAssistPromptTemplate {
base_branch,
conflicted_files: &conflicted_files,
workspace_note: workspace.note(),
};
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.to_string(),
one_shot_client: Arc::clone(&input.one_shot_client),
session_agent: input.session_agent,
session_update_versions: input.session_update_versions.clone(),
transcript: Arc::clone(&input.transcript),
}
}
async fn abort_rebase_after_assist_failure(input: &RebaseAssistInput) {
let folder = input.folder.clone();
if let Err(error) = input.git_client.abort_rebase(folder).await {
warn!(
session_id = %input.id,
error = %error,
"failed to abort rebase after assistance failure"
);
}
}
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?;
}
if let Err(error) = fs_client.remove_dir_all(folder).await {
warn!(
error = %error,
"failed to remove merged session worktree directory"
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use ag_agent::MockOneShotClient;
use ag_git::GitError;
use ag_session::SessionRole;
use mockall::Sequence;
use tempfile::{TempDir, tempdir};
use super::*;
use crate::infra::db::SessionOperationRow;
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())
}
fn test_one_shot_client() -> Arc<dyn OneShotClient> {
let mut one_shot_client = MockOneShotClient::new();
one_shot_client.expect_submit().times(0..).returning(|_| {
Ok(agent::OneShotSubmission {
response: ag_protocol::AgentResponse::plain("Existing session commit"),
stats: agent::SessionStats {
added_lines: 0,
deleted_lines: 0,
diff_state: agent::SessionDiffState::Unknown,
input_tokens: 0,
output_tokens: 0,
},
})
});
Arc::new(one_shot_client)
}
fn test_session_agent() -> AgentSelection {
AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini36Flash)
}
fn empty_review_request_client() -> Arc<dyn forge::ReviewRequestClient> {
Arc::new(forge::MockReviewRequestClient::new())
}
#[tokio::test]
async fn test_merge_session_rejects_linked_review_request_before_workflow_start() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.review_request(Some(linked_github_review_request()))
.build(),
);
let result = app
.sessions
.merge_session("session-id", &app.projects, &app.services)
.await;
let error = result.expect_err("linked review request should block merge workflow");
assert_eq!(
error.to_string(),
"Merge cannot run for linked review requests or while another stack session is active"
);
}
#[tokio::test]
async fn test_merge_session_rejects_orchestrator_before_workflow_start() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.role(SessionRole::Orchestrator)
.status(Status::Review)
.build(),
);
let result = app
.sessions
.merge_session("session-id", &app.projects, &app.services)
.await;
assert_eq!(
result
.expect_err("orchestrator merge should fail")
.to_string(),
"Orchestrator sessions do not own branch changes"
);
}
#[test]
fn test_real_sync_assist_client_new_owns_one_shot_client() {
let sync_assist_client = RealSyncAssistClient::new();
assert_eq!(Arc::strong_count(&sync_assist_client.one_shot_client), 1);
}
#[tokio::test]
async fn test_real_sync_assist_client_submits_utility_prompt() {
let folder = PathBuf::from("/tmp/sync-assist");
let expected_folder = folder.clone();
let mut one_shot_client = MockOneShotClient::new();
one_shot_client
.expect_submit()
.times(1)
.returning(move |request| {
assert_eq!(request.agent_kind, AgentKind::Claude);
assert_eq!(request.folder, expected_folder);
assert_eq!(request.model, AgentModel::ClaudeSonnet5);
assert_eq!(request.prompt, "Resolve sync conflicts");
assert_eq!(
request.request_kind,
ag_agent::AgentRequestKind::UtilityPrompt
);
Ok(agent::OneShotSubmission {
response: ag_protocol::AgentResponse::plain("resolved"),
stats: agent::SessionStats {
added_lines: 0,
deleted_lines: 0,
diff_state: agent::SessionDiffState::Unknown,
input_tokens: 0,
output_tokens: 0,
},
})
});
let sync_assist_client = RealSyncAssistClient {
one_shot_client: Arc::new(one_shot_client),
};
let result = sync_assist_client
.resolve_rebase_conflicts(
folder,
"Resolve sync conflicts".to_string(),
AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeSonnet5),
)
.await;
assert!(result.is_ok());
}
#[test]
fn test_has_unfinished_rebase_operation_matches_session_rebase() {
let operations = vec![
session_operation_row("op-1", "session-a", "reply"),
session_operation_row("op-2", "session-b", "rebase"),
session_operation_row("op-3", "session-a", "rebase"),
];
let has_rebase = has_unfinished_rebase_operation(&operations, "session-a");
assert!(has_rebase);
}
#[test]
fn test_has_unfinished_rebase_operation_ignores_other_sessions_and_kinds() {
let operations = vec![
session_operation_row("op-1", "session-a", "reply"),
session_operation_row("op-2", "session-b", "rebase"),
];
let has_rebase = has_unfinished_rebase_operation(&operations, "session-a");
assert!(!has_rebase);
}
fn session_operation_row(id: &str, session_id: &str, kind: &str) -> SessionOperationRow {
SessionOperationRow {
cancel_requested: false,
finished_at: None,
heartbeat_at: None,
id: id.to_string(),
kind: kind.to_string(),
last_error: None,
queued_at: 0,
session_id: session_id.to_string(),
started_at: None,
status: "queued".to_string(),
}
}
fn empty_transcript() -> Arc<Mutex<SessionTranscript>> {
Arc::new(Mutex::new(SessionTranscript::default()))
}
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 = AppRepositories::in_memory().await;
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,
assist_mode: RebaseAssistMode::OneShot,
child_pid: Arc::new(Mutex::new(None)),
db,
folder,
fs_client: test_fs_client(),
git_client,
id: "session-123".into(),
one_shot_client: test_one_shot_client(),
transcript: empty_transcript(),
rebase_plan: RebasePlan::target("main".to_string()),
session_agent: AgentSelection::new(
AgentKind::Antigravity,
AgentModel::Gemini36Flash,
),
session_update_versions: Arc::default(),
},
)
}
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 = AppRepositories::in_memory().await;
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,
archive_diff: false,
base_branch: "main".to_string(),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::infra::clock::RealClock),
db,
folder,
fs_client: test_fs_client(),
git_client,
id: "session-123".into(),
one_shot_client: test_one_shot_client(),
transcript: empty_transcript(),
repo_root,
session_update_versions: Arc::default(),
session_agent: AgentSelection::new(
AgentKind::Antigravity,
AgentModel::Gemini36Flash,
),
source_branch: "wt/session-123".to_string(),
status: Arc::new(Mutex::new(Status::Merging)),
},
)
}
async fn assert_managed_merge_metadata(
db: &AppRepositories,
expected_merged_commit_hash: &str,
) {
let merged_commit_hash = db
.sessions()
.load_session_merged_commit_hash("session-123")
.await
.expect("failed to load merged commit hash");
let archived_diff = db
.sessions()
.load_session_archived_diff("session-123")
.await
.expect("failed to load archived diff");
assert_eq!(
merged_commit_hash.as_deref(),
Some(expected_merged_commit_hash)
);
assert_eq!(archived_diff.as_deref(), Some("diff --git a/file b/file"));
}
fn build_sync_rebase_input_for_test(
folder: PathBuf,
git_client: Arc<dyn GitClient>,
sync_assist_client: Arc<dyn SyncAssistClient>,
) -> SyncRebaseAssistInput {
SyncRebaseAssistInput {
app_event_tx: None,
base_branch: "main".to_string(),
folder,
fs_client: test_fs_client(),
git_client,
session_agent: AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini36Flash),
sync_assist_client,
}
}
fn successful_sync_conflict_git_client() -> git::MockGitClient {
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);
mock_git_client
}
fn successful_sync_conflict_outcome() -> SyncMainOutcome {
SyncMainOutcome {
default_branch: "main".to_string(),
deferred_merged_session_ids: Vec::new(),
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()],
}
}
#[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,
RebaseAssistWorkspace::SessionWorktree,
)
.expect("rebase assist prompt should render");
let main_checkout_prompt = SessionManager::rebase_assist_prompt(
base_branch,
&conflicted_files,
RebaseAssistWorkspace::MainCheckout,
)
.expect("main-checkout rebase assist prompt should render");
assert!(prompt.contains("rebasing onto `main`"));
assert!(prompt.contains("- src/lib.rs"));
assert!(prompt.contains("- README.md"));
assert!(prompt.contains("session's isolated git worktree"));
assert!(main_checkout_prompt.contains("user's main repository checkout"));
assert!(main_checkout_prompt.contains("change only the conflicted files"));
assert!(prompt.contains("Remove every conflict marker"));
assert!(prompt.contains("inspect the commits involved"));
assert!(prompt.contains("understand their intent"));
assert!(prompt.contains("intended behavior from both sides"));
assert!(prompt.contains("Limit git inspection to read-only commands"));
assert!(prompt.contains("Never run mutating commands"));
assert!(prompt.contains("do not create commits"));
assert!(prompt.contains("repository-defined quality checks"));
assert!(prompt.contains("affected"));
assert!(prompt.contains("dependencies or dependents"));
assert!(prompt.contains("full repository\n test/check suite"));
assert!(prompt.contains("Return the required protocol JSON object"));
}
#[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 = AppRepositories::in_memory().await;
let project_id = database
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
database
.sessions()
.insert_session(
"session-id",
AgentModel::ClaudeSonnet5.as_str(),
"main",
"Review",
project_id,
)
.await
.expect("failed to insert session");
let existing_summary = "- Session branch updates README.";
database
.sessions()
.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
.sessions()
.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 = AppRepositories::in_memory().await;
let project_id = database
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
database
.sessions()
.insert_session(
"session-id",
AgentModel::ClaudeSonnet5.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
.sessions()
.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
.sessions()
.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 expected_merged_commit_hash = "704de31d0f4b5a1234567890abcdef1234567890";
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_head_hash()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok("parent-tip".to_string()) }));
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_head_hash()
.times(1)
.in_sequence(&mut sequence)
.returning(move |_| {
let expected_merged_commit_hash = expected_merged_commit_hash.to_string();
Box::pin(async move { Ok(expected_merged_commit_hash) })
});
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, mut input) =
build_merge_task_input_for_test(Arc::new(mock_git_client)).await;
input.archive_diff = true;
let project_id = input
.db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to insert project");
input
.db
.sessions()
.insert_session("session-123", "gpt-5.6-sol", "main", "Merging", project_id)
.await
.expect("failed to insert merge session row");
let db = input.db.clone();
let result = SessionManager::execute_merge_workflow(input).await;
let message = result.expect("merge workflow should succeed");
assert_eq!(message, "Successfully merged wt/session-123 into main");
assert_managed_merge_metadata(&db, expected_merged_commit_hash).await;
}
#[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_head_hash()
.times(1)
.in_sequence(&mut sequence)
.returning(|_| Box::pin(async { Ok("parent-tip".to_string()) }));
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 project_id = input
.db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to insert project");
input
.db
.sessions()
.insert_session("session-123", "gpt-5.6-sol", "main", "Merging", project_id)
.await
.expect("failed to insert merge session row");
let db = input.db.clone();
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 wt/session-123 are already present in main"
);
let merged_commit_hash = db
.sessions()
.load_session_merged_commit_hash("session-123")
.await
.expect("failed to load merged commit hash");
assert_eq!(merged_commit_hash, None);
let archived_diff = db
.sessions()
.load_session_archived_diff("session-123")
.await
.expect("failed to load archived diff");
assert_eq!(archived_diff, None);
}
#[tokio::test]
async fn test_rebase_assist_input_clone() {
let (tx, _rx) = mpsc::unbounded_channel();
let db = AppRepositories::in_memory().await;
let temp_dir = tempdir().expect("failed to create temporary test directory");
let input = RebaseAssistInput {
app_event_tx: tx,
assist_mode: RebaseAssistMode::OneShot,
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".into(),
one_shot_client: test_one_shot_client(),
transcript: empty_transcript(),
rebase_plan: RebasePlan::target("origin/main".to_string()),
session_agent: AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini36Flash),
session_update_versions: Arc::default(),
};
let cloned_input = input.clone();
assert_eq!(input.id, cloned_input.id);
assert_eq!(input.folder, cloned_input.folder);
assert_eq!(input.rebase_plan, cloned_input.rebase_plan);
assert_eq!(input.session_agent, cloned_input.session_agent);
}
#[tokio::test]
async fn test_resolve_session_rebase_target_keeps_local_base_for_unpublished_session() {
let db = AppRepositories::in_memory().await;
let temp_dir = tempdir().expect("failed to create temporary test directory");
let project_path = temp_dir.path().to_string_lossy().to_string();
let project_id = db
.projects()
.upsert_project(&project_path, Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session("sess-local", "gpt-5.6-sol", "main", "Review", project_id)
.await
.expect("failed to insert session");
let mut mock_git_client = git::MockGitClient::new();
mock_git_client.expect_fetch_remote().times(0);
let folder = temp_dir.path().join("sess-local");
let rebase_target = SessionManager::resolve_session_rebase_target(
&db,
&mock_git_client,
&folder,
"sess-local",
"main",
)
.await
.expect("failed to resolve local rebase target");
assert_eq!(rebase_target, "main");
}
#[tokio::test]
async fn test_resolve_session_rebase_target_fetches_remote_base_for_published_session() {
let db = AppRepositories::in_memory().await;
let temp_dir = tempdir().expect("failed to create temporary test directory");
let project_path = temp_dir.path().to_string_lossy().to_string();
let project_id = db
.projects()
.upsert_project(&project_path, Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session("sess-remote", "gpt-5.6-sol", "main", "Review", project_id)
.await
.expect("failed to insert session");
db.sessions()
.update_session_published_upstream_ref(
"sess-remote",
Some("origin/wt/sess-remote".to_string()),
)
.await
.expect("failed to set published upstream");
let folder = temp_dir.path().join("sess-remote");
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_fetch_remote()
.once()
.withf(|repo_path| repo_path.ends_with("sess-remote"))
.returning(|_| Box::pin(async { Ok(()) }));
let rebase_target = SessionManager::resolve_session_rebase_target(
&db,
&mock_git_client,
&folder,
"sess-remote",
"main",
)
.await
.expect("failed to resolve remote rebase target");
assert_eq!(rebase_target, "origin/main");
}
#[tokio::test]
async fn test_resolve_session_rebase_plan_uses_recorded_stack_base() {
let db = AppRepositories::in_memory().await;
let temp_dir = tempdir().expect("failed to create temporary test directory");
let project_path = temp_dir.path().to_string_lossy().to_string();
let project_id = db
.projects()
.upsert_project(&project_path, Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session("child-session", "gpt-5.6-sol", "main", "Review", project_id)
.await
.expect("failed to insert session");
db.sessions()
.update_session_stack_base_commit_hash("child-session", Some("parent-tip".to_string()))
.await
.expect("failed to set stack base hash");
let mut mock_git_client = git::MockGitClient::new();
mock_git_client.expect_fetch_remote().times(0);
let folder = temp_dir.path().join("child-session");
let rebase_plan = SessionManager::resolve_session_rebase_plan(
&db,
&mock_git_client,
&folder,
"child-session",
"main",
)
.await
.expect("failed to resolve rebase plan");
assert_eq!(
rebase_plan,
RebasePlan::Onto {
new_base: "main".to_string(),
old_base: "parent-tip".to_string(),
}
);
}
#[tokio::test]
async fn test_resolve_session_rebase_target_reports_published_fetch_failure() {
let db = AppRepositories::in_memory().await;
let temp_dir = tempdir().expect("failed to create temporary test directory");
let project_path = temp_dir.path().to_string_lossy().to_string();
let project_id = db
.projects()
.upsert_project(&project_path, Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session("sess-fetch", "gpt-5.6-sol", "main", "Review", project_id)
.await
.expect("failed to insert session");
db.sessions()
.update_session_published_upstream_ref(
"sess-fetch",
Some("origin/wt/sess-fetch".to_string()),
)
.await
.expect("failed to set published upstream");
let folder = temp_dir.path().join("sess-fetch");
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_fetch_remote()
.once()
.returning(|_| Box::pin(async { Err(GitError::OutputParse("fetch failed".into())) }));
let result = SessionManager::resolve_session_rebase_target(
&db,
&mock_git_client,
&folder,
"sess-fetch",
"main",
)
.await;
let error = result.expect_err("fetch failure should stop published rebase");
assert!(
error
.to_string()
.contains("Failed to fetch `origin` before rebasing published session branch"),
"error should name the published upstream remote"
);
}
#[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_diff()
.times(1)
.in_sequence(&mut sequence)
.returning(|_, _| Box::pin(async { Ok("diff --git a/a.rs b/a.rs".to_string()) }));
mock_git_client
.expect_has_commits_since()
.times(1)
.in_sequence(&mut sequence)
.withf(|_, base_branch| base_branch == "origin/main")
.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)
.withf(|_, base_branch, _, _, _| base_branch == "origin/main")
.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, mut input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
input.rebase_plan = RebasePlan::target("origin/main".to_string());
let result = SessionManager::execute_rebase_workflow(input).await;
let error = result.expect_err("rebase workflow should fail");
assert!(
error
.to_string()
.contains("Failed to sync: 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(Box::new(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(Box::new(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_tracks_initial_rebase_conflict_detail() {
let repeated_detail = "CONFLICT (content): Merge conflict in src/lib.rs".to_string();
let mut mock_git_client = git::MockGitClient::new();
let mut sequence = Sequence::new();
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({
let repeated_detail = repeated_detail.clone();
move |_, _| {
let repeated_detail = repeated_detail.clone();
Box::pin(async move {
Ok(git::RebaseStepResult::Conflict {
detail: repeated_detail,
})
})
}
});
for _ in 0..REBASE_ASSIST_POLICY.max_attempts {
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 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)
.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(input).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(Box::new(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_uses_resolved_rebase_target() {
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_rebase_start()
.once()
.withf(|_, rebase_target| rebase_target == "origin/main")
.returning(|_, _| Box::pin(async { Ok(git::RebaseStepResult::Completed) }));
let (_temp_dir, mut input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
input.rebase_plan = RebasePlan::target("origin/main".to_string());
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_uses_onto_plan() {
let mut mock_git_client = git::MockGitClient::new();
mock_git_client.expect_rebase_start().times(0);
mock_git_client
.expect_rebase_onto_start()
.once()
.withf(|_, new_base, old_base| new_base == "main" && old_base == "parent-tip")
.returning(|_, _, _| Box::pin(async { Ok(git::RebaseStepResult::Completed) }));
let (_temp_dir, mut input) =
build_rebase_assist_input_for_test(Arc::new(mock_git_client)).await;
input.rebase_plan = RebasePlan::Onto {
new_base: "main".to_string(),
old_base: "parent-tip".to_string(),
};
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 = "wt/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_ensure_merge_target_clean_blocks_dirty_main_checkout() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to insert project");
db.sessions()
.insert_session("session-123", "gpt-5.6-sol", "main", "Merging", project_id)
.await
.expect("failed to insert merge session row");
let status = Arc::new(Mutex::new(Status::Merging));
let session_update_versions = Arc::default();
let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel();
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok(false) }));
let status_transition = StatusTransition::from_parts(
app_event_tx,
Arc::new(crate::infra::clock::RealClock),
db.clone(),
"session-123",
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
let restore_context = MergeStartRestoreContext {
db: &db,
session_id: "session-123",
status_transition: &status_transition,
};
let result = SessionMergeService::ensure_merge_target_clean(
&mock_git_client,
PathBuf::from("/tmp/project"),
"main",
&restore_context,
)
.await;
let error = result.expect_err("dirty merge target should block merge");
assert_eq!(
error.to_string(),
"Merge cannot run while `main` has uncommitted changes.\nCommit or stash changes in \
`main`, then try again."
);
assert_eq!(
*status.lock().expect("status lock poisoned"),
Status::Review
);
}
#[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 mock_git_client = successful_sync_conflict_git_client();
let mut mock_sync_assist_client = MockSyncAssistClient::new();
mock_sync_assist_client
.expect_resolve_rebase_conflicts()
.times(1)
.returning(|_, _, _| Box::pin(async { Ok(()) }));
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let result = SessionManager::sync_main_for_project_with_assist_client(
Some("main".to_string()),
working_dir,
Some(app_event_tx),
test_fs_client(),
Arc::new(mock_git_client),
AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini36Flash),
Arc::new(mock_sync_assist_client),
)
.await;
let status_event = app_event_rx
.try_recv()
.expect("sync conflict status event should be emitted");
assert!(matches!(
status_event,
AppEvent::SyncMainConflictResolutionStarted {
conflicted_files
} if conflicted_files == vec!["src/lib.rs".to_string()]
));
assert!(app_event_rx.try_recv().is_err());
assert_eq!(
result,
Ok(successful_sync_conflict_outcome()),
"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,
None,
test_fs_client(),
Arc::new(mock_git_client),
AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini36Flash),
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 assist_input = RebaseAssistLoopInput::Project(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 = assist_input.load_conflicted_files(&[]).await;
let files = conflicted_files.expect("load_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 assist_input = RebaseAssistLoopInput::Project(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 = assist_input
.stage_and_check_for_conflicts(&["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);
}
#[tokio::test]
async fn test_finalize_rebase_task_triggers_auto_push_for_published_branch() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess-rebase",
"gemini-3.6-flash",
"main",
"Rebasing",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.update_session_published_upstream_ref(
"sess-rebase",
Some("origin/wt/sess-rebase".to_string()),
)
.await
.expect("failed to set published upstream ref");
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let branch_operation_lock = Arc::new(tokio::sync::Mutex::new(()));
let temp_dir = tempdir().expect("failed to create temp dir");
let folder = temp_dir.path().join("sess-rebase");
let session_update_versions = Arc::default();
let status = Arc::new(Mutex::new(Status::Rebasing));
let transcript = empty_transcript();
let clock: Arc<dyn Clock> = Arc::new(crate::infra::clock::RealClock);
let review_request_client = empty_review_request_client();
let status_transition = StatusTransition::from_parts(
app_event_tx.clone(),
Arc::clone(&clock),
db.clone(),
"sess-rebase",
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
let push_started = Arc::new(tokio::sync::Notify::new());
let release_push = Arc::new(tokio::sync::Notify::new());
let git_client: Arc<dyn GitClient> = Arc::new(blocking_auto_push_git_client(
Arc::clone(&push_started),
Arc::clone(&release_push),
));
SessionManager::finalize_rebase_task(FinalizeRebaseInput {
app_event_tx: &app_event_tx,
branch_operation_lock: &branch_operation_lock,
clock: &clock,
db: &db,
folder: &folder,
git_client: &git_client,
id: "sess-rebase",
one_shot_client: &test_one_shot_client(),
rebase_result: Ok("Successfully synced wt/sess-rebase onto main".to_string()),
review_request_client: &review_request_client,
session_agent: test_session_agent(),
session_update_versions: &session_update_versions,
status_transition: &status_transition,
transcript: &transcript,
})
.await;
tokio::time::timeout(std::time::Duration::from_secs(2), push_started.notified())
.await
.expect("timed out waiting for auto-push to start");
assert!(
branch_operation_lock.try_lock().is_err(),
"post-rebase auto-push should retain branch-operation ownership"
);
release_push.notify_one();
let sync_events = tokio::time::timeout(std::time::Duration::from_secs(2), async {
let mut sync_events = Vec::new();
while sync_events.len() < 2 {
let event = app_event_rx.recv().await.expect("missing app event");
if let AppEvent::PublishedBranchSyncUpdated {
session_id,
sync_operation_id,
sync_status,
..
} = event
{
sync_events.push((session_id, sync_operation_id, sync_status));
}
}
sync_events
})
.await
.expect("timed out waiting for sync events");
assert_eq!(sync_events[0].0, "sess-rebase");
assert_eq!(sync_events[0].2, PublishedBranchSyncStatus::InProgress);
assert_eq!(sync_events[1].0, "sess-rebase");
assert_eq!(sync_events[1].2, PublishedBranchSyncStatus::Succeeded);
assert_eq!(sync_events[0].1, sync_events[1].1);
let transcript_text = transcript
.lock()
.expect("transcript lock poisoned")
.replay_text()
.unwrap_or_default();
assert!(transcript_text.contains("[Sync] Successfully synced"));
}
fn blocking_auto_push_git_client(
push_started: Arc<tokio::sync::Notify>,
release_push: Arc<tokio::sync::Notify>,
) -> git::MockGitClient {
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_in_progress_operation()
.once()
.returning(|_| Box::pin(async { Ok(None) }));
mock_git_client
.expect_detect_git_info()
.once()
.returning(|_| Box::pin(async { Some("wt/sess-reb".to_string()) }));
mock_git_client
.expect_push_current_branch_to_remote_branch()
.once()
.withf(|session_folder, remote_branch_name| {
session_folder.ends_with("sess-rebase") && remote_branch_name == "wt/sess-rebase"
})
.returning(move |_, _| {
let push_started = Arc::clone(&push_started);
let release_push = Arc::clone(&release_push);
Box::pin(async move {
push_started.notify_one();
release_push.notified().await;
Ok("origin/wt/sess-rebase".to_string())
})
});
mock_git_client
}
async fn insert_published_rebase_session_with_review_request(db: &AppRepositories) {
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess-rebase",
"gemini-3.6-flash",
"main",
"Rebasing",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.update_session_published_upstream_ref(
"sess-rebase",
Some("origin/wt/sess-rebase".to_string()),
)
.await
.expect("failed to set published upstream ref");
db.reviews()
.update_session_review_request("sess-rebase", Some(linked_github_review_request()))
.await
.expect("failed to persist review request");
db.sessions()
.update_session_summary("sess-rebase", "The rebase preserves the existing goal.")
.await
.expect("failed to persist session summary");
}
fn linked_github_review_request() -> crate::domain::session::ReviewRequest {
crate::domain::session::ReviewRequest {
last_refreshed_at: 100,
summary: forge::ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: forge::ForgeKind::GitHub,
source_branch: "wt/sess-rebase".to_string(),
state: crate::domain::session::ReviewRequestState::Open,
status_summary: Some("Draft".to_string()),
target_branch: "main".to_string(),
title: "Old title".to_string(),
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
},
}
}
fn metadata_sync_git_client() -> git::MockGitClient {
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_head_commit_message()
.once()
.withf(|session_folder| session_folder.ends_with("sess-rebase"))
.returning(|_| {
Box::pin(async {
Ok(Some(
"Refresh queued sync metadata\n\n- Preserve sync details.".to_string(),
))
})
});
mock_git_client
.expect_in_progress_operation()
.once()
.returning(|_| Box::pin(async { Ok(None) }));
mock_git_client
.expect_detect_git_info()
.once()
.returning(|_| Box::pin(async { Some("wt/sess-reb".to_string()) }));
mock_git_client
.expect_push_current_branch_to_remote_branch()
.once()
.withf(|session_folder, remote_branch_name| {
session_folder.ends_with("sess-rebase") && remote_branch_name == "wt/sess-rebase"
})
.returning(|_, _| Box::pin(async { Ok("origin/wt/sess-rebase".to_string()) }));
mock_git_client.expect_repo_url().once().returning(|_| {
Box::pin(async { Ok("https://github.com/agentty-xyz/agentty.git".to_string()) })
});
mock_git_client
}
fn github_forge_remote() -> forge::ForgeRemote {
forge::ForgeRemote {
command_working_directory: None,
forge_kind: forge::ForgeKind::GitHub,
host: "github.com".to_string(),
namespace: "agentty-xyz".to_string(),
project: "agentty".to_string(),
repo_url: "https://github.com/agentty-xyz/agentty.git".to_string(),
web_url: "https://github.com/agentty-xyz/agentty".to_string(),
}
}
fn metadata_sync_review_request_client(folder: PathBuf) -> forge::MockReviewRequestClient {
let mut mock_review_request_client = forge::MockReviewRequestClient::new();
mock_review_request_client
.expect_detect_remote()
.once()
.returning(|_| Ok(github_forge_remote()));
mock_review_request_client
.expect_review_request_metadata()
.once()
.returning(|_, _| {
Box::pin(async {
Ok(forge::ReviewRequestMetadata {
body: "Old details.".to_string(),
title: "Old title".to_string(),
})
})
});
mock_review_request_client
.expect_sync_review_request_metadata()
.once()
.withf(move |remote, display_id, input| {
remote.command_working_directory.as_deref() == Some(folder.as_path())
&& display_id == "#42"
&& input.title.as_ref().is_some_and(|title| {
title.current == "Old title" && title.desired == "Old title"
})
&& input.body.as_ref().is_some_and(|body| {
body.current == "Old details."
&& body.desired == "Old details.\n\n- Preserve sync details."
})
})
.returning(|_, _, _| {
Box::pin(async {
Ok(forge::ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: forge::ForgeKind::GitHub,
source_branch: "wt/sess-rebase".to_string(),
state: crate::domain::session::ReviewRequestState::Open,
status_summary: Some("Open".to_string()),
target_branch: "main".to_string(),
title: "Old title".to_string(),
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
})
})
});
mock_review_request_client
}
fn metadata_sync_one_shot_client() -> Arc<dyn OneShotClient> {
let mut one_shot_client = MockOneShotClient::new();
one_shot_client.expect_submit().once().returning(|_| {
Ok(agent::OneShotSubmission {
response: ag_protocol::AgentResponse::plain(
r#"{"title":"Old title","description":"Old details.\n\n- Preserve sync details.","is_title_change_significant":false}"#,
),
stats: agent::SessionStats::default(),
})
});
Arc::new(one_shot_client)
}
async fn collect_published_branch_sync_statuses(
app_event_rx: &mut mpsc::UnboundedReceiver<AppEvent>,
) -> Vec<PublishedBranchSyncStatus> {
tokio::time::timeout(std::time::Duration::from_secs(2), async {
let mut sync_events = Vec::new();
while sync_events.len() < 2 {
let event = app_event_rx.recv().await.expect("missing app event");
if let AppEvent::PublishedBranchSyncUpdated { sync_status, .. } = event {
sync_events.push(sync_status);
}
}
sync_events
})
.await
.expect("timed out waiting for sync events")
}
#[tokio::test]
async fn test_finalize_rebase_task_syncs_review_request_metadata_after_auto_push() {
let db = AppRepositories::in_memory().await;
insert_published_rebase_session_with_review_request(&db).await;
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let temp_dir = tempdir().expect("failed to create temp dir");
let folder = temp_dir.path().join("sess-rebase");
let session_update_versions = Arc::default();
let status = Arc::new(Mutex::new(Status::Rebasing));
let transcript = empty_transcript();
let clock: Arc<dyn Clock> = Arc::new(crate::infra::clock::RealClock);
let status_transition = StatusTransition::from_parts(
app_event_tx.clone(),
Arc::clone(&clock),
db.clone(),
"sess-rebase",
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
let git_client: Arc<dyn GitClient> = Arc::new(metadata_sync_git_client());
let review_request_client: Arc<dyn forge::ReviewRequestClient> =
Arc::new(metadata_sync_review_request_client(folder.clone()));
SessionManager::finalize_rebase_task(FinalizeRebaseInput {
app_event_tx: &app_event_tx,
branch_operation_lock: &Arc::new(tokio::sync::Mutex::new(())),
clock: &clock,
db: &db,
folder: &folder,
git_client: &git_client,
id: "sess-rebase",
one_shot_client: &metadata_sync_one_shot_client(),
rebase_result: Ok("Successfully synced wt/sess-rebase onto main".to_string()),
review_request_client: &review_request_client,
session_agent: AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini36Flash),
session_update_versions: &session_update_versions,
status_transition: &status_transition,
transcript: &transcript,
})
.await;
let sync_events = collect_published_branch_sync_statuses(&mut app_event_rx).await;
let review_request = db
.reviews()
.load_session_review_request("sess-rebase")
.await
.expect("failed to load review request")
.expect("review request should remain linked");
assert_eq!(
sync_events,
vec![
PublishedBranchSyncStatus::InProgress,
PublishedBranchSyncStatus::Succeeded,
]
);
assert_eq!(review_request.title, "Old title");
}
#[test]
fn test_review_request_session_summary_after_rebase_skips_database_errors() {
let session_summary = Err(DbError::Query(sqlx::Error::PoolClosed));
let summary = SessionManager::review_request_session_summary_after_rebase(
"sess-rebase",
session_summary,
);
assert_eq!(summary, None);
}
#[tokio::test]
async fn test_finalize_rebase_task_warns_when_commit_message_lookup_fails() {
let db = AppRepositories::in_memory().await;
insert_published_rebase_session_with_review_request(&db).await;
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let temp_dir = tempdir().expect("failed to create temp dir");
let folder = temp_dir.path().join("sess-rebase");
let session_update_versions = Arc::default();
let status = Arc::new(Mutex::new(Status::Rebasing));
let transcript = empty_transcript();
let clock: Arc<dyn Clock> = Arc::new(crate::infra::clock::RealClock);
let status_transition = StatusTransition::from_parts(
app_event_tx.clone(),
Arc::clone(&clock),
db.clone(),
"sess-rebase",
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
let mut mock_git_client = git::MockGitClient::new();
mock_git_client
.expect_in_progress_operation()
.once()
.returning(|_| Box::pin(async { Ok(None) }));
mock_git_client
.expect_detect_git_info()
.once()
.returning(|_| Box::pin(async { Some("wt/sess-reb".to_string()) }));
mock_git_client
.expect_push_current_branch_to_remote_branch()
.once()
.returning(|_, _| Box::pin(async { Ok("origin/wt/sess-rebase".to_string()) }));
mock_git_client
.expect_head_commit_message()
.once()
.returning(|_| {
Box::pin(async {
Err(git::GitError::OutputParse(
"commit message unavailable".to_string(),
))
})
});
let git_client: Arc<dyn GitClient> = Arc::new(mock_git_client);
let review_request_client = empty_review_request_client();
SessionManager::finalize_rebase_task(FinalizeRebaseInput {
app_event_tx: &app_event_tx,
branch_operation_lock: &Arc::new(tokio::sync::Mutex::new(())),
clock: &clock,
db: &db,
folder: &folder,
git_client: &git_client,
id: "sess-rebase",
one_shot_client: &test_one_shot_client(),
rebase_result: Ok("Successfully synced wt/sess-rebase onto main".to_string()),
review_request_client: &review_request_client,
session_agent: test_session_agent(),
session_update_versions: &session_update_versions,
status_transition: &status_transition,
transcript: &transcript,
})
.await;
let sync_events = collect_published_branch_sync_statuses(&mut app_event_rx).await;
let transcript_text = transcript
.lock()
.expect("transcript lock poisoned")
.replay_text()
.unwrap_or_default();
assert_eq!(
sync_events,
vec![
PublishedBranchSyncStatus::InProgress,
PublishedBranchSyncStatus::Succeeded,
]
);
assert!(transcript_text.contains("[Review Request Sync Warning]"));
assert!(transcript_text.contains("commit message unavailable"));
}
#[tokio::test]
async fn test_finalize_rebase_task_skips_auto_push_without_published_branch() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess-no-push",
"gemini-3.6-flash",
"main",
"Rebasing",
project_id,
)
.await
.expect("failed to insert session");
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let temp_dir = tempdir().expect("failed to create temp dir");
let folder = temp_dir.path().join("sess-no-push");
let session_update_versions = Arc::default();
let status = Arc::new(Mutex::new(Status::Rebasing));
let transcript = empty_transcript();
let clock: Arc<dyn Clock> = Arc::new(crate::infra::clock::RealClock);
let review_request_client = empty_review_request_client();
let status_transition = StatusTransition::from_parts(
app_event_tx.clone(),
Arc::clone(&clock),
db.clone(),
"sess-no-push",
Arc::clone(&session_update_versions),
Arc::clone(&status),
);
let git_client: Arc<dyn GitClient> = Arc::new(git::MockGitClient::new());
SessionManager::finalize_rebase_task(FinalizeRebaseInput {
app_event_tx: &app_event_tx,
branch_operation_lock: &Arc::new(tokio::sync::Mutex::new(())),
clock: &clock,
db: &db,
folder: &folder,
git_client: &git_client,
id: "sess-no-push",
one_shot_client: &test_one_shot_client(),
rebase_result: Ok("Successfully synced wt/sess-no-push onto main".to_string()),
review_request_client: &review_request_client,
session_agent: test_session_agent(),
session_update_versions: &session_update_versions,
status_transition: &status_transition,
transcript: &transcript,
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let mut sync_event_count = 0;
let mut stack_sync_event_count = 0;
while let Ok(event) = app_event_rx.try_recv() {
if matches!(&event, AppEvent::PublishedBranchSyncUpdated { .. }) {
sync_event_count += 1;
}
if matches!(&event, AppEvent::StackedParentSyncCompleted { .. }) {
stack_sync_event_count += 1;
}
}
assert_eq!(
sync_event_count, 0,
"should not emit sync events without published branch"
);
assert_eq!(
stack_sync_event_count, 1,
"should emit one stacked parent sync completion event"
);
let transcript_text = transcript
.lock()
.expect("transcript lock poisoned")
.replay_text()
.unwrap_or_default();
assert!(transcript_text.contains("[Sync] Successfully synced"));
}
}