Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
dialect (thinking-field mapping, model_id stripping, empty-content
tool-call message fix, stream_options.include_usage), with kimi-cli
source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)
Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
share command, credit bar: deleted (single local session lane;
paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
gate/tier/team/ZDR fields, app/subscription.rs watch loop,
dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
RPC; Kigi is zero-egress — nothing to share or retain remotely)
Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
(https://www.kimi.com/code/authorize_device?user_code=..., verified
against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
wiremock fixture URLs into the real browser (root cause of the
"garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
1184 lines
47 KiB
Rust
1184 lines
47 KiB
Rust
//! Git worktree operations: create, list, remove, apply.
|
|
//!
|
|
//! Core worktree lifecycle logic lives in [`kigi_workspace::worktree`].
|
|
//! This module re-exports everything from there and adds session-aware
|
|
//! functions that depend on shell-specific infrastructure (persistence,
|
|
//! auth, registry client, storage client, session restore).
|
|
use crate::util::config::WorktreeType as ShellWorktreeType;
|
|
use anyhow::{Context, Result};
|
|
use kigi_workspace::session::git::find_git_root_from_path;
|
|
pub use kigi_workspace::worktree::*;
|
|
use std::path::Path;
|
|
const WORKTREE_LOG: &str = "xai_worktree";
|
|
impl From<ShellWorktreeType> for WorktreeType {
|
|
fn from(t: ShellWorktreeType) -> Self {
|
|
match t {
|
|
ShellWorktreeType::Linked => WorktreeType::Linked,
|
|
ShellWorktreeType::Standalone => WorktreeType::Standalone,
|
|
ShellWorktreeType::Git => WorktreeType::Git,
|
|
}
|
|
}
|
|
}
|
|
impl From<WorktreeType> for ShellWorktreeType {
|
|
fn from(t: WorktreeType) -> Self {
|
|
match t {
|
|
WorktreeType::Linked => ShellWorktreeType::Linked,
|
|
WorktreeType::Standalone => ShellWorktreeType::Standalone,
|
|
WorktreeType::Git => ShellWorktreeType::Git,
|
|
}
|
|
}
|
|
}
|
|
/// Create a worktree for the resume-session flow, detecting jj vs git automatically.
|
|
///
|
|
/// When `git_ref` is set, forces a clean checkout of that ref (same as the
|
|
/// manual `create_from_worktree_sync` path used by `grok -w --ref`).
|
|
async fn create_worktree_for_resume(
|
|
source_cwd: &str,
|
|
copy_mode: WorktreeCopyMode,
|
|
worktree_type: ShellWorktreeType,
|
|
git_ref: Option<String>,
|
|
) -> Result<CreateWorktreeFromWorktreeResponse> {
|
|
let copy_mode = if git_ref.is_some() {
|
|
WorktreeCopyMode::Clean
|
|
} else {
|
|
copy_mode
|
|
};
|
|
let wt_req = CreateWorktreeFromWorktreeRequest {
|
|
source_worktree_path: source_cwd.to_owned(),
|
|
new_session_id: uuid::Uuid::now_v7().to_string(),
|
|
copy_mode,
|
|
git_ref,
|
|
worktree_type: Some(WorktreeType::from(worktree_type)),
|
|
label: None,
|
|
cancellation_token: None,
|
|
resolved_dest_path: None,
|
|
};
|
|
let source = std::path::Path::new(source_cwd);
|
|
if find_git_root_from_path(source)
|
|
.ok()
|
|
.is_some_and(|root| kigi_workspace::session::git::detect_vcs_kind(&root).is_jj())
|
|
{
|
|
create_jj_workspace(&wt_req).await
|
|
} else {
|
|
create_worktree_from_worktree_sync(&wt_req).await
|
|
}
|
|
}
|
|
/// Best-effort cleanup of a worktree created during a failed resume flow.
|
|
async fn cleanup_worktree_on_failure(source_cwd: &str, worktree_path: &str) {
|
|
let wt = std::path::Path::new(worktree_path);
|
|
if !wt.exists() {
|
|
return;
|
|
}
|
|
let is_jj = find_git_root_from_path(std::path::Path::new(source_cwd))
|
|
.ok()
|
|
.is_some_and(|root| kigi_workspace::session::git::detect_vcs_kind(&root).is_jj());
|
|
if is_jj {
|
|
if let Err(e) = remove_jj_workspace(worktree_path).await {
|
|
tracing::warn!(error = % e, "failed to clean up jj workspace after failure");
|
|
}
|
|
} else {
|
|
let wt_path = wt.to_path_buf();
|
|
match tokio::task::spawn_blocking(move || kigi_fast_worktree::remove_worktree(&wt_path))
|
|
.await
|
|
{
|
|
Ok(Ok(_)) => {}
|
|
Ok(Err(e)) => {
|
|
tracing::warn!(
|
|
error = % e, "fast remove_worktree failed during cleanup, trying rm"
|
|
);
|
|
let _ = tokio::fs::remove_dir_all(wt).await;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = % e,
|
|
"remove_worktree task panicked during cleanup, trying rm"
|
|
);
|
|
let _ = tokio::fs::remove_dir_all(wt).await;
|
|
}
|
|
}
|
|
if let Ok(root) = find_git_root_from_path(std::path::Path::new(source_cwd)) {
|
|
let _ = kigi_workspace::session::git::git_cli(&root, &["worktree", "prune"]).await;
|
|
}
|
|
}
|
|
}
|
|
/// Check out a persisted HEAD commit in a worktree, with fetch fallback.
|
|
///
|
|
/// Always stashes any dirty state (the worktree may carry copies of the
|
|
/// source's uncommitted changes under `copy_mode: dirty`) before invoking
|
|
/// `git checkout` so the caller can surface the stash ref to the user.
|
|
pub(crate) async fn checkout_persisted_head_in_worktree(
|
|
worktree_path: &str,
|
|
head_commit: Option<&str>,
|
|
session_id: &str,
|
|
) -> kigi_workspace::session::git::CheckoutSessionOutcome {
|
|
let sha = match head_commit {
|
|
Some(s) if !s.is_empty() => s,
|
|
_ => return kigi_workspace::session::git::CheckoutSessionOutcome::default(),
|
|
};
|
|
kigi_workspace::session::git::checkout_session_commit(
|
|
Path::new(worktree_path),
|
|
sha,
|
|
true,
|
|
session_id,
|
|
)
|
|
.await
|
|
}
|
|
/// Decision returned to the worktree restore caller.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub(crate) struct WorktreeRestoreDecision {
|
|
pub code_restored: bool,
|
|
pub restore_summary: Option<String>,
|
|
pub restore_degree: Option<kigi_workspace::session::git::RestoreDegree>,
|
|
}
|
|
/// Thin wire-format adapter over the shared
|
|
/// [`kigi_workspace::session::git::build_restore_decision`] helper.
|
|
pub(crate) fn build_worktree_restore_outcome(
|
|
head_commit: Option<&str>,
|
|
outcome: &kigi_workspace::session::git::CheckoutSessionOutcome,
|
|
kind: kigi_workspace::session::git::RestoreKind,
|
|
) -> WorktreeRestoreDecision {
|
|
let d = kigi_workspace::session::git::build_restore_decision(head_commit, outcome, kind);
|
|
WorktreeRestoreDecision {
|
|
code_restored: d.restored,
|
|
restore_summary: d.summary,
|
|
restore_degree: d.degree,
|
|
}
|
|
}
|
|
use crate::session::persistence::{ResolvedLocalSession, resolve_local_session_for_repo};
|
|
/// Combined backend helper: resolve a session across all worktree roots
|
|
/// belonging to the same repo as `current_cwd`.
|
|
pub fn resolve_session_repo_wide(
|
|
session_id: &str,
|
|
current_cwd: &std::path::Path,
|
|
) -> Result<Option<ResolvedLocalSession>> {
|
|
let candidates = candidate_worktree_cwds_for_same_repo(current_cwd)?;
|
|
let refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
|
|
Ok(resolve_local_session_for_repo(session_id, &refs))
|
|
}
|
|
/// Orchestrate the full "resume session in worktree" flow.
|
|
///
|
|
/// Shell-side orchestration: composes client ops (session persistence,
|
|
/// auth, registry) with server ops (worktree creation, git, fetch+extract)
|
|
/// dispatched through `WorkspaceOps`.
|
|
pub async fn resume_session_in_worktree(
|
|
req: &ResumeSessionInWorktreeRequest,
|
|
ops: &kigi_workspace::WorkspaceOps,
|
|
worktree_type_default: ShellWorktreeType,
|
|
restore_code_default: bool,
|
|
registry_client: Option<&crate::agent::session_registry_client::SessionRegistryClient>,
|
|
auth_manager: Option<std::sync::Arc<crate::auth::AuthManager>>,
|
|
agent_id: &str,
|
|
) -> Result<ResumeSessionInWorktreeResponse> {
|
|
use kigi_workspace::session::git::effective_worktree_path;
|
|
tracing::info!(
|
|
target : WORKTREE_LOG, session_id = % req.session_id, restore_code = ? req
|
|
.restore_code, restore_code_default, effective_restore_code = req.restore_code
|
|
.unwrap_or(restore_code_default),
|
|
"RESTORE_CODE_DEBUG: resume_session_in_worktree entry"
|
|
);
|
|
let cwd_path = std::path::Path::new(req.source_cwd.as_str());
|
|
let local_resolution = resolve_session_repo_wide(&req.session_id, cwd_path);
|
|
if let Ok(Some(resolved)) = local_resolution {
|
|
tracing::info!(
|
|
target : WORKTREE_LOG, session_id = % req.session_id, resolved_cwd = %
|
|
resolved.cwd, kind = ? resolved.resolution_kind,
|
|
"RESUME_LOCAL_RESOLVED: session found via repo-wide lookup"
|
|
);
|
|
return resume_local_session_in_worktree(
|
|
req,
|
|
ops,
|
|
&resolved.session_id,
|
|
&resolved.cwd,
|
|
worktree_type_default,
|
|
restore_code_default,
|
|
registry_client,
|
|
auth_manager,
|
|
agent_id,
|
|
)
|
|
.await;
|
|
}
|
|
let client = registry_client.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"Session {} not found locally and session registry is not available \
|
|
(auth may be missing or registry is disabled)",
|
|
req.session_id,
|
|
)
|
|
})?;
|
|
tracing::info!(
|
|
session_id = % req.session_id,
|
|
"Restoring remote session: creating worktree first to keep source clean"
|
|
);
|
|
let worktree_type = req
|
|
.worktree_type
|
|
.map(ShellWorktreeType::from)
|
|
.unwrap_or(worktree_type_default);
|
|
let wt_resp = create_worktree_for_resume(
|
|
&req.source_cwd,
|
|
req.copy_mode,
|
|
worktree_type,
|
|
req.git_ref.clone(),
|
|
)
|
|
.await?;
|
|
let record = client
|
|
.get_session(&req.session_id)
|
|
.await
|
|
.context("fetching session record for remote restore")?;
|
|
let turn = crate::session::restore::resolve_restore_turn(&record, None);
|
|
let memory_dl_future = crate::session::restore::download_to_tempfile(
|
|
client,
|
|
&req.session_id,
|
|
"memory.tar.gz",
|
|
turn,
|
|
);
|
|
let state_dl_future = async {
|
|
Err(anyhow::anyhow!(
|
|
"session-state archive restore unavailable in this build"
|
|
))
|
|
};
|
|
let (memory_dl, state_dl) = tokio::join!(memory_dl_future, state_dl_future);
|
|
let codebase_ok = false;
|
|
let _memory_result =
|
|
crate::session::restore::apply_memory_download(memory_dl, &wt_resp.worktree_path).await;
|
|
let (session_state_result, local_session_id) =
|
|
crate::session::restore::apply_session_state_download(
|
|
state_dl,
|
|
&req.session_id,
|
|
&wt_resp.worktree_path,
|
|
)
|
|
.await;
|
|
if session_state_result.is_skipped() {
|
|
cleanup_worktree_on_failure(&req.source_cwd, &wt_resp.worktree_path).await;
|
|
anyhow::bail!(
|
|
"Session {} restored codebase but session-state archive was unavailable -- \
|
|
conversation history cannot be recovered. Retry in a few moments.",
|
|
req.session_id,
|
|
);
|
|
}
|
|
let worktree_root = std::path::Path::new(&wt_resp.worktree_path);
|
|
let source_path = std::path::Path::new(&req.source_cwd);
|
|
let source_git_root = wt_resp.source_git_root.as_deref().map(std::path::Path::new);
|
|
let effective_cwd = effective_worktree_path(worktree_root, source_path, source_git_root)
|
|
.to_string_lossy()
|
|
.to_string();
|
|
let restore_summary = None;
|
|
let restore_degree = if codebase_ok {
|
|
Some(kigi_workspace::session::git::RestoreDegree::Full)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(ResumeSessionInWorktreeResponse {
|
|
session_id: local_session_id,
|
|
worktree_path: wt_resp.worktree_path,
|
|
effective_cwd,
|
|
remote_restored: true,
|
|
parent_session_id: req.session_id.clone(),
|
|
chat_messages_copied: session_state_result.files_copied as usize,
|
|
updates_copied: if session_state_result.updates_restored {
|
|
1
|
|
} else {
|
|
0
|
|
},
|
|
code_restored: codebase_ok,
|
|
restore_summary,
|
|
restore_degree,
|
|
})
|
|
}
|
|
/// Local-session resume: create worktree from source, fork session into it.
|
|
async fn resume_local_session_in_worktree(
|
|
req: &ResumeSessionInWorktreeRequest,
|
|
#[allow(unused_variables)] ops: &kigi_workspace::WorkspaceOps,
|
|
resolved_session_id: &str,
|
|
resolved_source_cwd: &str,
|
|
worktree_type_default: ShellWorktreeType,
|
|
restore_code_default: bool,
|
|
registry_client: Option<&crate::agent::session_registry_client::SessionRegistryClient>,
|
|
auth_manager: Option<std::sync::Arc<crate::auth::AuthManager>>,
|
|
agent_id: &str,
|
|
) -> Result<ResumeSessionInWorktreeResponse> {
|
|
use crate::session::fork::{ForkSessionRequest, fork_session};
|
|
use kigi_workspace::session::git::effective_worktree_path;
|
|
let worktree_type = req
|
|
.worktree_type
|
|
.map(ShellWorktreeType::from)
|
|
.unwrap_or(worktree_type_default);
|
|
let wt_resp = create_worktree_for_resume(
|
|
resolved_source_cwd,
|
|
req.copy_mode,
|
|
worktree_type,
|
|
req.git_ref.clone(),
|
|
)
|
|
.await?;
|
|
tracing::info!(
|
|
target : WORKTREE_LOG, restore_code = ? req.restore_code, restore_code_default,
|
|
effective = req.restore_code.unwrap_or(restore_code_default), git_ref = req
|
|
.git_ref.as_deref(), resolved_session_id, worktree_path = % wt_resp
|
|
.worktree_path,
|
|
"RESTORE_CODE_DEBUG: resume_local_session_in_worktree, about to check restore_code"
|
|
);
|
|
let mut decision = WorktreeRestoreDecision {
|
|
code_restored: false,
|
|
restore_summary: None,
|
|
restore_degree: None,
|
|
};
|
|
if req.restore_code.unwrap_or(restore_code_default) {
|
|
let is_jj = find_git_root_from_path(std::path::Path::new(resolved_source_cwd))
|
|
.ok()
|
|
.is_some_and(|root| kigi_workspace::session::git::detect_vcs_kind(&root).is_jj());
|
|
if !is_jj {
|
|
if kigi_workspace::session::git::should_warn_registry_disabled(
|
|
is_jj,
|
|
registry_client.is_some(),
|
|
) {
|
|
kigi_workspace::session::git::warn_registry_disabled_restore(resolved_session_id);
|
|
}
|
|
let info = crate::session::info::Info {
|
|
id: agent_client_protocol::SessionId::new(resolved_session_id.to_owned()),
|
|
cwd: resolved_source_cwd.to_owned(),
|
|
};
|
|
let summary_path = crate::session::persistence::session_dir(&info).join("summary.json");
|
|
let head_commit = std::fs::read_to_string(&summary_path)
|
|
.ok()
|
|
.and_then(|raw| {
|
|
serde_json::from_str::<crate::session::persistence::Summary>(&raw).ok()
|
|
})
|
|
.and_then(|s| s.head_commit);
|
|
tracing::info!(
|
|
target : WORKTREE_LOG, head_commit = ? head_commit, summary_path = %
|
|
summary_path.display(),
|
|
"RESTORE_CODE_DEBUG: loaded head_commit from summary"
|
|
);
|
|
let outcome = checkout_persisted_head_in_worktree(
|
|
&wt_resp.worktree_path,
|
|
head_commit.as_deref(),
|
|
resolved_session_id,
|
|
)
|
|
.await;
|
|
use kigi_workspace::session::git::RestoreKind;
|
|
let kind = if !outcome.checked_out {
|
|
RestoreKind::CheckoutFailed
|
|
} else {
|
|
match registry_client {
|
|
None => RestoreKind::RegistryOff,
|
|
Some(client) => {
|
|
let _ = (client, ops);
|
|
RestoreKind::RegistryOff
|
|
}
|
|
}
|
|
};
|
|
decision = build_worktree_restore_outcome(head_commit.as_deref(), &outcome, kind);
|
|
}
|
|
}
|
|
let WorktreeRestoreDecision {
|
|
code_restored,
|
|
restore_summary,
|
|
restore_degree,
|
|
} = decision;
|
|
let worktree_root = std::path::Path::new(&wt_resp.worktree_path);
|
|
let source_path = std::path::Path::new(resolved_source_cwd);
|
|
let source_git_root = wt_resp.source_git_root.as_deref().map(std::path::Path::new);
|
|
let effective_cwd = effective_worktree_path(worktree_root, source_path, source_git_root)
|
|
.to_string_lossy()
|
|
.to_string();
|
|
let fork_req = ForkSessionRequest {
|
|
source_session_id: resolved_session_id.to_owned(),
|
|
source_cwd: resolved_source_cwd.to_owned(),
|
|
new_cwd: effective_cwd.clone(),
|
|
session_kind: Some("worktree".to_string()),
|
|
source_workspace_dir: Some(resolved_source_cwd.to_owned()),
|
|
..Default::default()
|
|
};
|
|
let fork_resp = match fork_session(fork_req).await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
cleanup_worktree_on_failure(resolved_source_cwd, &wt_resp.worktree_path).await;
|
|
return Err(anyhow::anyhow!("Failed to fork session into worktree: {e}"));
|
|
}
|
|
};
|
|
Ok(ResumeSessionInWorktreeResponse {
|
|
session_id: fork_resp.new_session_id,
|
|
worktree_path: wt_resp.worktree_path,
|
|
effective_cwd,
|
|
remote_restored: false,
|
|
parent_session_id: resolved_session_id.to_owned(),
|
|
chat_messages_copied: fork_resp.chat_messages_copied,
|
|
updates_copied: fork_resp.updates_copied,
|
|
code_restored,
|
|
restore_summary,
|
|
restore_degree,
|
|
})
|
|
}
|
|
/// Orchestrate session rehydration: recreate the git worktree at the exact
|
|
/// path and restore all session state using the original session ID.
|
|
///
|
|
pub async fn rehydrate_session_in_worktree(
|
|
req: &RehydrateSessionRequest,
|
|
#[allow(unused_variables)] ops: &kigi_workspace::WorkspaceOps,
|
|
registry_client: Option<&crate::agent::session_registry_client::SessionRegistryClient>,
|
|
) -> Result<RehydrateSessionResponse> {
|
|
let worktree_path_str = req.worktree_path.as_deref().unwrap_or(&req.source_cwd);
|
|
let repo_root = Path::new(&req.repo_root);
|
|
let worktree_path = Path::new(worktree_path_str);
|
|
if !repo_root.exists() {
|
|
anyhow::bail!(
|
|
"Repository root '{}' does not exist. \
|
|
Ensure the repo is cloned before calling rehydrate.",
|
|
req.repo_root
|
|
);
|
|
}
|
|
let session_summary_exists = crate::util::kigi_home::sessions_cwd_dir(&req.source_cwd)
|
|
.join(&req.session_id)
|
|
.join("summary.json")
|
|
.exists();
|
|
if worktree_path.exists() && session_summary_exists {
|
|
tracing::info!(
|
|
session_id = % req.session_id, worktree_path = % worktree_path_str,
|
|
"rehydrate: worktree and session state already exist, skipping"
|
|
);
|
|
return Ok(RehydrateSessionResponse {
|
|
session_id: req.session_id.clone(),
|
|
worktree_path: worktree_path_str.to_string(),
|
|
effective_cwd: req.source_cwd.clone(),
|
|
codebase_restored: false,
|
|
session_state_restored: false,
|
|
memory_restored: false,
|
|
warnings: vec![],
|
|
});
|
|
}
|
|
if !worktree_path.exists() {
|
|
tracing::info!(
|
|
session_id = % req.session_id, % worktree_path_str,
|
|
"rehydrate: creating worktree"
|
|
);
|
|
if let Some(parent) = worktree_path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
let source = req.repo_root.clone();
|
|
let dest = worktree_path_str.to_string();
|
|
let session_id = req.session_id.clone();
|
|
let btrfs_delegate = btrfs_delegate_from_env();
|
|
tokio::task::spawn_blocking(move || {
|
|
use kigi_fast_worktree::{
|
|
CreationMode, IgnoredFilesMode, WorkingTreeMode, WorktreeBuilder,
|
|
};
|
|
let mut builder = WorktreeBuilder::new(&source, &dest)
|
|
.working_tree_mode(WorkingTreeMode::CleanAll)
|
|
.ignored_files_mode(IgnoredFilesMode::Skip)
|
|
.creation_mode(CreationMode::Linked)
|
|
.worktree_kind(kigi_fast_worktree::WorktreeKind::Fork)
|
|
.session_id(session_id);
|
|
if let Some(delegate) = btrfs_delegate {
|
|
builder = builder.btrfs_delegate(delegate);
|
|
}
|
|
builder.create()
|
|
})
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("worktree creation task failed: {e}"))??;
|
|
}
|
|
let client = registry_client.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"Session registry client is required for rehydration \
|
|
(auth may be missing or registry is disabled)"
|
|
)
|
|
})?;
|
|
let record = client
|
|
.get_session(&req.session_id)
|
|
.await
|
|
.context("fetching session record for rehydration")?;
|
|
let turn = crate::session::restore::resolve_restore_turn(&record, None);
|
|
let memory_dl_future = crate::session::restore::download_to_tempfile(
|
|
client,
|
|
&req.session_id,
|
|
"memory.tar.gz",
|
|
turn,
|
|
);
|
|
let state_dl_future = async {
|
|
Err(anyhow::anyhow!(
|
|
"session-state archive restore unavailable in this build"
|
|
))
|
|
};
|
|
let (memory_dl, state_dl) = tokio::join!(memory_dl_future, state_dl_future);
|
|
let _ = ops;
|
|
let mut warnings: Vec<String> = Vec::new();
|
|
let codebase_restored = false;
|
|
let memory_result =
|
|
crate::session::restore::apply_memory_download(memory_dl, &req.source_cwd).await;
|
|
let session_state_result = crate::session::restore::apply_session_state_in_place(
|
|
state_dl,
|
|
&req.session_id,
|
|
&req.source_cwd,
|
|
)
|
|
.await;
|
|
if session_state_result.is_skipped() {
|
|
warnings.push(
|
|
"Session state archive was unavailable; conversation history not restored.".to_string(),
|
|
);
|
|
}
|
|
Ok(RehydrateSessionResponse {
|
|
session_id: req.session_id.clone(),
|
|
worktree_path: worktree_path_str.to_string(),
|
|
effective_cwd: req.source_cwd.clone(),
|
|
codebase_restored,
|
|
session_state_restored: !session_state_result.is_skipped(),
|
|
memory_restored: memory_result.sessions_copied > 0,
|
|
warnings,
|
|
})
|
|
}
|
|
#[cfg(all(test, unix))]
|
|
mod tests {
|
|
use super::*;
|
|
use serial_test::serial;
|
|
#[test]
|
|
fn resume_request_deserializes_with_defaults() {
|
|
let json = r#"{"sessionId":"s1","sourceCwd":"/project"}"#;
|
|
let req: ResumeSessionInWorktreeRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(req.session_id, "s1");
|
|
assert_eq!(req.source_cwd, "/project");
|
|
assert!(matches!(req.copy_mode, WorktreeCopyMode::Dirty));
|
|
assert!(req.worktree_type.is_none());
|
|
assert!(req.git_ref.is_none());
|
|
}
|
|
#[test]
|
|
fn resume_request_deserializes_explicit_fields() {
|
|
let json = r#"{
|
|
"sessionId": "abc",
|
|
"sourceCwd": "/work",
|
|
"copyMode": "clean",
|
|
"worktreeType": "standalone",
|
|
"gitRef": "main"
|
|
}"#;
|
|
let req: ResumeSessionInWorktreeRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(req.session_id, "abc");
|
|
assert!(matches!(req.copy_mode, WorktreeCopyMode::Clean));
|
|
assert_eq!(req.worktree_type, Some(WorktreeType::Standalone));
|
|
assert_eq!(req.git_ref.as_deref(), Some("main"));
|
|
}
|
|
#[test]
|
|
fn resume_response_round_trips() {
|
|
let resp = ResumeSessionInWorktreeResponse {
|
|
session_id: "forked-id".into(),
|
|
worktree_path: "/wt/root".into(),
|
|
effective_cwd: "/wt/root/sub".into(),
|
|
remote_restored: true,
|
|
parent_session_id: "original-id".into(),
|
|
chat_messages_copied: 42,
|
|
updates_copied: 100,
|
|
code_restored: true,
|
|
restore_summary: Some(
|
|
"checked out abc12345, staged: true, unstaged: false, untracked: 3".into(),
|
|
),
|
|
restore_degree: Some(kigi_workspace::session::git::RestoreDegree::Full),
|
|
};
|
|
let json = serde_json::to_string(&resp).unwrap();
|
|
let deser: ResumeSessionInWorktreeResponse = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deser.session_id, "forked-id");
|
|
assert_eq!(deser.effective_cwd, "/wt/root/sub");
|
|
assert!(deser.remote_restored);
|
|
assert_eq!(deser.parent_session_id, "original-id");
|
|
assert_eq!(deser.chat_messages_copied, 42);
|
|
assert_eq!(deser.updates_copied, 100);
|
|
assert!(deser.code_restored);
|
|
assert_eq!(
|
|
deser.restore_summary.as_deref(),
|
|
Some("checked out abc12345, staged: true, unstaged: false, untracked: 3")
|
|
);
|
|
assert_eq!(
|
|
deser.restore_degree,
|
|
Some(kigi_workspace::session::git::RestoreDegree::Full)
|
|
);
|
|
}
|
|
fn ck_outcome(
|
|
checked_out: bool,
|
|
stash_ref: Option<&str>,
|
|
skipped: Option<&str>,
|
|
) -> kigi_workspace::session::git::CheckoutSessionOutcome {
|
|
kigi_workspace::session::git::CheckoutSessionOutcome {
|
|
checked_out,
|
|
stash_ref: stash_ref.map(str::to_owned),
|
|
stash_skipped_reason: skipped.map(str::to_owned),
|
|
}
|
|
}
|
|
/// When checkout failed AND stash was skipped (e.g. in-progress
|
|
/// merge), the meta still surfaces the skipped reason rather than
|
|
/// going silent.
|
|
#[test]
|
|
fn worktree_restore_outcome_checkout_failed_surfaces_stash_skipped_reason() {
|
|
use kigi_workspace::session::git::RestoreKind;
|
|
let d = build_worktree_restore_outcome(
|
|
Some("0123456789abcdef"),
|
|
&ck_outcome(false, None, Some("MERGE_HEAD present")),
|
|
RestoreKind::RegistryOff,
|
|
);
|
|
assert!(!d.code_restored);
|
|
assert!(d.restore_degree.is_none());
|
|
let s = d.restore_summary.unwrap();
|
|
assert!(s.contains("restore aborted"));
|
|
assert!(s.contains("; stash skipped: MERGE_HEAD present"));
|
|
}
|
|
#[test]
|
|
fn worktree_restore_outcome_surfaces_stash_skipped_reason_on_success() {
|
|
use kigi_workspace::session::git::RestoreKind;
|
|
let d = build_worktree_restore_outcome(
|
|
Some("0123456789abcdef"),
|
|
&ck_outcome(true, None, Some("MERGE_HEAD present")),
|
|
RestoreKind::RegistryOff,
|
|
);
|
|
let s = d.restore_summary.unwrap();
|
|
assert!(s.contains("; stash skipped: MERGE_HEAD present"));
|
|
}
|
|
#[test]
|
|
fn resume_response_serializes_degree_when_set() {
|
|
let resp = ResumeSessionInWorktreeResponse {
|
|
session_id: "s".into(),
|
|
worktree_path: "w".into(),
|
|
effective_cwd: "e".into(),
|
|
remote_restored: false,
|
|
parent_session_id: "p".into(),
|
|
chat_messages_copied: 0,
|
|
updates_copied: 0,
|
|
code_restored: true,
|
|
restore_summary: Some("checked out abc".into()),
|
|
restore_degree: Some(kigi_workspace::session::git::RestoreDegree::HeadOnly),
|
|
};
|
|
let json = serde_json::to_string(&resp).unwrap();
|
|
assert!(json.contains("\"restoreDegree\":\"head_only\""));
|
|
assert!(json.contains("\"restoreSummary\":\"checked out abc\""));
|
|
let deser: ResumeSessionInWorktreeResponse = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(
|
|
deser.restore_degree,
|
|
Some(kigi_workspace::session::git::RestoreDegree::HeadOnly)
|
|
);
|
|
assert_eq!(deser.restore_summary.as_deref(), Some("checked out abc"));
|
|
}
|
|
/// Unknown degree strings must fail deserialisation rather than
|
|
/// silently round-tripping as a typo.
|
|
#[test]
|
|
fn resume_response_rejects_unknown_degree_string() {
|
|
let json = r#"{
|
|
"sessionId": "s",
|
|
"worktreePath": "w",
|
|
"effectiveCwd": "e",
|
|
"remoteRestored": false,
|
|
"parentSessionId": "p",
|
|
"chatMessagesCopied": 0,
|
|
"updatesCopied": 0,
|
|
"codeRestored": true,
|
|
"restoreDegree": "full_"
|
|
}"#;
|
|
let r: Result<ResumeSessionInWorktreeResponse, _> = serde_json::from_str(json);
|
|
assert!(r.is_err(), "typo \"full_\" must fail to deserialise");
|
|
}
|
|
#[test]
|
|
fn resume_response_camel_case_keys() {
|
|
let resp = ResumeSessionInWorktreeResponse {
|
|
session_id: "s".into(),
|
|
worktree_path: "w".into(),
|
|
effective_cwd: "e".into(),
|
|
remote_restored: false,
|
|
parent_session_id: "p".into(),
|
|
chat_messages_copied: 0,
|
|
updates_copied: 0,
|
|
code_restored: false,
|
|
restore_summary: None,
|
|
restore_degree: None,
|
|
};
|
|
let json = serde_json::to_string(&resp).unwrap();
|
|
assert!(json.contains("sessionId"));
|
|
assert!(json.contains("worktreePath"));
|
|
assert!(json.contains("effectiveCwd"));
|
|
assert!(json.contains("remoteRestored"));
|
|
assert!(json.contains("parentSessionId"));
|
|
assert!(json.contains("chatMessagesCopied"));
|
|
assert!(json.contains("updatesCopied"));
|
|
assert!(json.contains("codeRestored"));
|
|
assert!(!json.contains("restoreSummary"));
|
|
assert!(!json.contains("restoreDegree"));
|
|
assert!(!json.contains("session_id"));
|
|
assert!(!json.contains("worktree_path"));
|
|
}
|
|
#[test]
|
|
fn test_dirty_summary_serialization() {
|
|
let summary = DirtyStateSummary {
|
|
staged_count: 3,
|
|
modified_count: 5,
|
|
deleted_count: 1,
|
|
untracked_count: 12,
|
|
has_partially_staged: true,
|
|
skipped_dirs: vec!["node_modules".to_string(), "target".to_string()],
|
|
};
|
|
let json = serde_json::to_string(&summary).unwrap();
|
|
assert!(json.contains("\"stagedCount\":3"));
|
|
assert!(json.contains("\"modifiedCount\":5"));
|
|
assert!(json.contains("\"hasPartiallyStaged\":true"));
|
|
assert!(json.contains("\"skippedDirs\":["));
|
|
}
|
|
#[test]
|
|
fn test_copy_mode_default_is_dirty() {
|
|
let mode: WorktreeCopyMode = Default::default();
|
|
assert_eq!(mode, WorktreeCopyMode::Dirty);
|
|
}
|
|
#[test]
|
|
fn test_copy_mode_deserialization() {
|
|
let clean: WorktreeCopyMode = serde_json::from_str("\"clean\"").unwrap();
|
|
assert_eq!(clean, WorktreeCopyMode::Clean);
|
|
let dirty: WorktreeCopyMode = serde_json::from_str("\"dirty\"").unwrap();
|
|
assert_eq!(dirty, WorktreeCopyMode::Dirty);
|
|
}
|
|
#[test]
|
|
fn test_created_status_without_copied_changes() {
|
|
let status = WorktreeStatus::Created {
|
|
session_id: "test-123".to_string(),
|
|
worktree_path: "/path/to/worktree".to_string(),
|
|
commit: "abc123".to_string(),
|
|
source_git_root: None,
|
|
copied_changes: None,
|
|
};
|
|
let json = serde_json::to_string(&status).unwrap();
|
|
assert!(!json.contains("copiedChanges"));
|
|
assert!(json.contains("\"status\":\"created\""));
|
|
assert!(json.contains("\"sessionId\":\"test-123\""));
|
|
}
|
|
#[test]
|
|
fn test_created_status_with_copied_changes() {
|
|
let status = WorktreeStatus::Created {
|
|
session_id: "test-123".to_string(),
|
|
worktree_path: "/path/to/worktree".to_string(),
|
|
commit: "abc123".to_string(),
|
|
source_git_root: Some("/path/to/source".to_string()),
|
|
copied_changes: Some(CopiedChangesSummary {
|
|
staged_copied: 3,
|
|
modified_copied: 5,
|
|
untracked_copied: 12,
|
|
deletions_applied: 1,
|
|
warnings: vec![],
|
|
}),
|
|
};
|
|
let json = serde_json::to_string(&status).unwrap();
|
|
assert!(json.contains("copiedChanges"));
|
|
assert!(json.contains("\"stagedCopied\":3"));
|
|
}
|
|
#[test]
|
|
fn remove_request_legacy_worktree_path_only() {
|
|
let json = r#"{"worktreePath": "/path/to/wt", "force": true}"#;
|
|
let req: RemoveWorktreeRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(req.worktree_path.as_deref(), Some("/path/to/wt"));
|
|
assert!(req.id_or_path.is_none());
|
|
assert!(req.force);
|
|
assert!(!req.dry_run);
|
|
}
|
|
#[test]
|
|
fn remove_request_new_id_or_path_only() {
|
|
let json = r#"{"idOrPath": "wt-abc123", "dryRun": true}"#;
|
|
let req: RemoveWorktreeRequest = serde_json::from_str(json).unwrap();
|
|
assert!(req.worktree_path.is_none());
|
|
assert_eq!(req.id_or_path.as_deref(), Some("wt-abc123"));
|
|
assert!(!req.force);
|
|
assert!(req.dry_run);
|
|
}
|
|
#[test]
|
|
fn remove_request_both_fields_deserializes_but_handler_rejects() {
|
|
let json = r#"{"worktreePath": "/explicit", "idOrPath": "fallback"}"#;
|
|
let req: RemoveWorktreeRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(req.worktree_path.as_deref(), Some("/explicit"));
|
|
assert_eq!(req.id_or_path.as_deref(), Some("fallback"));
|
|
}
|
|
#[test]
|
|
fn remove_response_omits_resolved_path_when_none() {
|
|
let resp = RemoveWorktreeResponse {
|
|
removed: true,
|
|
resolved_path: None,
|
|
};
|
|
let json = serde_json::to_string(&resp).unwrap();
|
|
assert!(!json.contains("resolvedPath"));
|
|
}
|
|
#[test]
|
|
fn remove_response_includes_resolved_path_when_present() {
|
|
let resp = RemoveWorktreeResponse {
|
|
removed: true,
|
|
resolved_path: Some("/resolved/path".into()),
|
|
};
|
|
let json = serde_json::to_string(&resp).unwrap();
|
|
assert!(json.contains("\"resolvedPath\":\"/resolved/path\""));
|
|
}
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_worktree_by_id_or_path_nonexistent_returns_none() {
|
|
let result = resolve_worktree_by_id_or_path("/nonexistent/path/xyz123").unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_worktree_by_id_or_path_existing_dir_returns_path() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let path = tmp.path().to_str().unwrap();
|
|
let result = resolve_worktree_by_id_or_path(path).unwrap();
|
|
assert_eq!(result.unwrap(), tmp.path());
|
|
}
|
|
fn make_wt_record(path: &str, source_repo: &str) -> kigi_fast_worktree::WorktreeRecord {
|
|
kigi_fast_worktree::WorktreeRecord {
|
|
id: format!("wt-{}", path.replace('/', "-")),
|
|
path: std::path::PathBuf::from(path),
|
|
source_repo: std::path::PathBuf::from(source_repo),
|
|
repo_name: "repo".into(),
|
|
kind: kigi_fast_worktree::WorktreeKind::Session,
|
|
creation_mode: "linked".into(),
|
|
git_ref: None,
|
|
head_commit: None,
|
|
session_id: None,
|
|
creator_pid: None,
|
|
created_at: 0,
|
|
last_accessed_at: None,
|
|
status: kigi_fast_worktree::WorktreeStatus::Alive,
|
|
metadata: None,
|
|
}
|
|
}
|
|
#[test]
|
|
fn build_candidate_list_cwd_equals_main_root() {
|
|
let result = build_candidate_list("/repo/main", "/repo/main", &[], &[]);
|
|
assert_eq!(result, vec!["/repo/main"]);
|
|
}
|
|
#[test]
|
|
fn build_candidate_list_cwd_differs_from_main_root() {
|
|
let result = build_candidate_list("/repo/wt-1", "/repo/main", &[], &[]);
|
|
assert_eq!(result, vec!["/repo/wt-1", "/repo/main"]);
|
|
}
|
|
#[test]
|
|
fn build_candidate_list_includes_db_records_sorted() {
|
|
let records = vec![
|
|
make_wt_record("/repo/wt-c", "/repo/main"),
|
|
make_wt_record("/repo/wt-a", "/repo/main"),
|
|
make_wt_record("/repo/wt-b", "/repo/main"),
|
|
];
|
|
let result = build_candidate_list("/repo/main", "/repo/main", &records, &[]);
|
|
assert_eq!(
|
|
result,
|
|
vec!["/repo/main", "/repo/wt-a", "/repo/wt-b", "/repo/wt-c"]
|
|
);
|
|
}
|
|
#[test]
|
|
fn build_candidate_list_dedupes_cwd_and_main_root() {
|
|
let records = vec![
|
|
make_wt_record("/repo/main", "/repo/main"),
|
|
make_wt_record("/repo/wt-1", "/repo/main"),
|
|
make_wt_record("/repo/wt-1", "/repo/main"),
|
|
];
|
|
let result = build_candidate_list("/repo/wt-1", "/repo/main", &records, &[]);
|
|
assert_eq!(result, vec!["/repo/wt-1", "/repo/main"]);
|
|
}
|
|
#[test]
|
|
fn repo_wide_resolve_finds_session_in_sibling_cwd_skipping_remote() {
|
|
use crate::session::persistence::LocalSessionResolutionKind;
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let root = tmp.path();
|
|
let exact_cwd = "/project/main";
|
|
let sibling_cwd = "/project/worktree-1";
|
|
let encoded = crate::util::kigi_home::encode_cwd_dirname(sibling_cwd);
|
|
let session_dir = root.join(&encoded).join("sess-remote-123");
|
|
std::fs::create_dir_all(&session_dir).unwrap();
|
|
std::fs::write(session_dir.join("summary.json"), b"{}").unwrap();
|
|
let candidates: &[&str] = &[exact_cwd, sibling_cwd];
|
|
let result = crate::session::persistence::resolve_local_session_for_repo_in_root(
|
|
"sess-remote-123",
|
|
candidates,
|
|
root,
|
|
);
|
|
let resolved = result.expect("should find session in sibling cwd");
|
|
assert_eq!(resolved.session_id, "sess-remote-123");
|
|
assert_eq!(resolved.cwd, sibling_cwd);
|
|
assert_eq!(
|
|
resolved.resolution_kind,
|
|
LocalSessionResolutionKind::SameRepoDifferentCwd
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn resume_in_worktree_falls_through_to_remote_when_not_found_locally() {
|
|
let req = ResumeSessionInWorktreeRequest {
|
|
session_id: "nonexistent-session-id".to_string(),
|
|
source_cwd: "/tmp/definitely-not-a-repo".to_string(),
|
|
copy_mode: WorktreeCopyMode::Dirty,
|
|
worktree_type: None,
|
|
restore_code: Some(true),
|
|
git_ref: None,
|
|
};
|
|
let ops = kigi_workspace::WorkspaceOps::for_test();
|
|
let result = resume_session_in_worktree(
|
|
&req,
|
|
&ops,
|
|
ShellWorktreeType::Linked,
|
|
false,
|
|
None,
|
|
None,
|
|
"test-agent",
|
|
)
|
|
.await;
|
|
let err = result.expect_err("should fail when session not found and no registry");
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("not found locally") && msg.contains("registry"),
|
|
"expected registry-unavailable error, got: {msg}"
|
|
);
|
|
}
|
|
/// Test helper: Initialize a git repo at the given path
|
|
fn init_git_repo(path: &std::path::Path) {
|
|
crate::test_support::ensure_hermetic_git_on_path();
|
|
std::process::Command::new("git")
|
|
.current_dir(path)
|
|
.args(["init"])
|
|
.output()
|
|
.unwrap();
|
|
std::process::Command::new("git")
|
|
.current_dir(path)
|
|
.args(["config", "user.email", "test@test.com"])
|
|
.output()
|
|
.unwrap();
|
|
std::process::Command::new("git")
|
|
.current_dir(path)
|
|
.args(["config", "user.name", "Test"])
|
|
.output()
|
|
.unwrap();
|
|
}
|
|
/// Test helper: Stage and commit all files
|
|
fn git_commit_all(path: &std::path::Path, message: &str) {
|
|
std::process::Command::new("git")
|
|
.current_dir(path)
|
|
.args(["add", "."])
|
|
.output()
|
|
.unwrap();
|
|
std::process::Command::new("git")
|
|
.current_dir(path)
|
|
.args(["commit", "-m", message])
|
|
.output()
|
|
.unwrap();
|
|
}
|
|
#[tokio::test]
|
|
async fn create_worktree_for_resume_produces_independent_worktree() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let repo_path = tmp.path().join("repo");
|
|
std::fs::create_dir(&repo_path).unwrap();
|
|
init_git_repo(&repo_path);
|
|
std::fs::write(repo_path.join("file.txt"), "original").unwrap();
|
|
git_commit_all(&repo_path, "initial");
|
|
let source_cwd = repo_path.to_string_lossy().to_string();
|
|
let wt_resp = create_worktree_for_resume(
|
|
&source_cwd,
|
|
WorktreeCopyMode::Clean,
|
|
ShellWorktreeType::Linked,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("worktree creation should succeed");
|
|
let wt_path = std::path::Path::new(&wt_resp.worktree_path);
|
|
assert!(wt_path.exists(), "worktree directory should exist");
|
|
std::fs::write(wt_path.join("worktree_only.txt"), "from worktree").unwrap();
|
|
assert!(
|
|
!repo_path.join("worktree_only.txt").exists(),
|
|
"file written in worktree must not appear in source repo",
|
|
);
|
|
assert!(wt_path.join("file.txt").exists());
|
|
assert!(repo_path.join("file.txt").exists());
|
|
}
|
|
#[tokio::test]
|
|
async fn create_worktree_for_resume_honors_git_ref() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let repo_path = tmp.path().join("repo");
|
|
std::fs::create_dir(&repo_path).unwrap();
|
|
init_git_repo(&repo_path);
|
|
std::process::Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["checkout", "-b", "main"])
|
|
.output()
|
|
.unwrap();
|
|
std::fs::write(repo_path.join("file.txt"), "on-main").unwrap();
|
|
git_commit_all(&repo_path, "initial");
|
|
std::process::Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["checkout", "-b", "feature"])
|
|
.output()
|
|
.unwrap();
|
|
std::fs::write(repo_path.join("file.txt"), "on-feature").unwrap();
|
|
git_commit_all(&repo_path, "feature commit");
|
|
std::fs::write(repo_path.join("dirty.txt"), "uncommitted").unwrap();
|
|
let source_cwd = repo_path.to_string_lossy().to_string();
|
|
let wt_resp = create_worktree_for_resume(
|
|
&source_cwd,
|
|
WorktreeCopyMode::Dirty,
|
|
ShellWorktreeType::Linked,
|
|
Some("main".into()),
|
|
)
|
|
.await
|
|
.expect("worktree creation with git_ref should succeed");
|
|
let wt_path = std::path::Path::new(&wt_resp.worktree_path);
|
|
let contents = std::fs::read_to_string(wt_path.join("file.txt")).unwrap();
|
|
assert_eq!(
|
|
contents, "on-main",
|
|
"worktree should be checked out at the requested ref"
|
|
);
|
|
assert!(
|
|
!wt_path.join("dirty.txt").exists(),
|
|
"dirty overlay must not apply when git_ref is set"
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn cleanup_worktree_on_failure_removes_created_worktree() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let repo_path = tmp.path().join("repo");
|
|
std::fs::create_dir(&repo_path).unwrap();
|
|
init_git_repo(&repo_path);
|
|
std::fs::write(repo_path.join("file.txt"), "original").unwrap();
|
|
git_commit_all(&repo_path, "initial");
|
|
let source_cwd = repo_path.to_string_lossy().to_string();
|
|
let wt_resp = create_worktree_for_resume(
|
|
&source_cwd,
|
|
WorktreeCopyMode::Clean,
|
|
ShellWorktreeType::Linked,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("worktree creation should succeed");
|
|
let wt_path = std::path::Path::new(&wt_resp.worktree_path);
|
|
assert!(wt_path.exists(), "worktree should exist before cleanup");
|
|
cleanup_worktree_on_failure(&source_cwd, &wt_resp.worktree_path).await;
|
|
assert!(
|
|
!wt_path.exists(),
|
|
"worktree directory should be removed after cleanup",
|
|
);
|
|
assert!(repo_path.join("file.txt").exists());
|
|
}
|
|
#[test]
|
|
fn worktree_base_dir_extracts_repo_name() {
|
|
let base = worktree_base_dir(Path::new("/home/user/projects/my-repo"));
|
|
assert!(base.ends_with("worktrees/projects-my-repo"));
|
|
}
|
|
/// Helper: get HEAD commit SHA from a git repo.
|
|
fn git_head_sha(path: &std::path::Path) -> String {
|
|
let out = std::process::Command::new("git")
|
|
.current_dir(path)
|
|
.args(["rev-parse", "HEAD"])
|
|
.output()
|
|
.unwrap();
|
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
|
}
|
|
#[tokio::test]
|
|
async fn checkout_persisted_head_checks_out_older_commit() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let repo_path = tmp.path().join("repo");
|
|
std::fs::create_dir(&repo_path).unwrap();
|
|
init_git_repo(&repo_path);
|
|
std::fs::write(repo_path.join("a.txt"), "first").unwrap();
|
|
git_commit_all(&repo_path, "first commit");
|
|
let first_sha = git_head_sha(&repo_path);
|
|
std::fs::write(repo_path.join("b.txt"), "second").unwrap();
|
|
git_commit_all(&repo_path, "second commit");
|
|
let second_sha = git_head_sha(&repo_path);
|
|
assert_ne!(first_sha, second_sha);
|
|
let wt_path = tmp.path().join("wt");
|
|
std::process::Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["worktree", "add", "--detach", wt_path.to_str().unwrap()])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(git_head_sha(&wt_path), second_sha);
|
|
let wt_str = wt_path.to_string_lossy().to_string();
|
|
let result =
|
|
checkout_persisted_head_in_worktree(&wt_str, Some(&first_sha), "sess-ck").await;
|
|
assert!(result.checked_out, "should have performed checkout");
|
|
assert_eq!(git_head_sha(&wt_path), first_sha);
|
|
}
|
|
#[tokio::test]
|
|
async fn checkout_persisted_head_noop_when_none() {
|
|
let outcome = checkout_persisted_head_in_worktree("/tmp/irrelevant", None, "sess").await;
|
|
assert!(!outcome.checked_out, "None head_commit should be a no-op",);
|
|
}
|
|
#[tokio::test]
|
|
async fn checkout_persisted_head_noop_on_empty_string() {
|
|
let outcome =
|
|
checkout_persisted_head_in_worktree("/tmp/irrelevant", Some(""), "sess").await;
|
|
assert!(
|
|
!outcome.checked_out,
|
|
"empty string head_commit should be a no-op",
|
|
);
|
|
}
|
|
/// Integration: a worktree with seeded dirty state must surface a
|
|
/// stash ref AND end up clean after `checkout_persisted_head_in_worktree`.
|
|
/// Mirrors `copy_mode: dirty` worktree creation where the worktree
|
|
/// inherits the source's uncommitted changes.
|
|
#[tokio::test]
|
|
async fn checkout_persisted_head_stashes_dirty_worktree_state() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let repo_path = tmp.path().join("repo");
|
|
std::fs::create_dir(&repo_path).unwrap();
|
|
init_git_repo(&repo_path);
|
|
std::fs::write(repo_path.join("a.txt"), "first").unwrap();
|
|
git_commit_all(&repo_path, "first commit");
|
|
let first_sha = git_head_sha(&repo_path);
|
|
std::fs::write(repo_path.join("b.txt"), "second").unwrap();
|
|
git_commit_all(&repo_path, "second commit");
|
|
let wt_path = tmp.path().join("wt");
|
|
std::process::Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["worktree", "add", "--detach", wt_path.to_str().unwrap()])
|
|
.output()
|
|
.unwrap();
|
|
std::fs::write(wt_path.join("a.txt"), "dirty mod").unwrap();
|
|
std::fs::write(wt_path.join("untracked.txt"), "new").unwrap();
|
|
let wt_str = wt_path.to_string_lossy().to_string();
|
|
let outcome =
|
|
checkout_persisted_head_in_worktree(&wt_str, Some(&first_sha), "sess-dirty-wt").await;
|
|
assert!(outcome.checked_out);
|
|
assert!(
|
|
outcome.stash_ref.is_some(),
|
|
"dirty worktree must produce stash ref"
|
|
);
|
|
assert!(outcome.stash_skipped_reason.is_none());
|
|
let porcelain = std::process::Command::new("git")
|
|
.current_dir(&wt_path)
|
|
.args(["status", "--porcelain"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
String::from_utf8_lossy(&porcelain.stdout).trim().is_empty(),
|
|
"working tree should be clean after stash"
|
|
);
|
|
assert!(!wt_path.join("untracked.txt").exists());
|
|
let stash_list = std::process::Command::new("git")
|
|
.current_dir(&wt_path)
|
|
.args(["stash", "list"])
|
|
.output()
|
|
.unwrap();
|
|
let list_out = String::from_utf8_lossy(&stash_list.stdout).into_owned();
|
|
assert!(
|
|
list_out.contains("grok: pre-restore-code sess-dirty-wt"),
|
|
"stash list missing session label: {list_out}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn test_background_copy_guard_registers_and_unregisters() {
|
|
let context = BackgroundCopyContext::new();
|
|
let worktree_path = "/test/worktree/guard-test".to_string();
|
|
let cancellation_token = tokio_util::sync::CancellationToken::new();
|
|
{
|
|
let _guard = BackgroundCopyGuard::new(
|
|
context.clone(),
|
|
worktree_path.clone(),
|
|
cancellation_token.clone(),
|
|
);
|
|
}
|
|
let was_cancelled = context.cancel(&worktree_path);
|
|
assert!(!was_cancelled);
|
|
}
|
|
#[test]
|
|
fn test_background_copy_context_cancel_via_context() {
|
|
let context = BackgroundCopyContext::new();
|
|
let worktree_path = "/test/worktree/cancel-test".to_string();
|
|
let cancellation_token = tokio_util::sync::CancellationToken::new();
|
|
let _guard = BackgroundCopyGuard::new(
|
|
context.clone(),
|
|
worktree_path.clone(),
|
|
cancellation_token.clone(),
|
|
);
|
|
assert!(!cancellation_token.is_cancelled());
|
|
let was_cancelled = context.cancel(&worktree_path);
|
|
assert!(was_cancelled);
|
|
assert!(cancellation_token.is_cancelled());
|
|
}
|
|
}
|