feat(v0.8.44): P0 state root migration to ~/.codewhale with legacy compat
#2011: migrate app state to ~/.codewhale - Add CodeWhalePaths: codewhale_home(), legacy_deepseek_home(), resolve_state_dir(), ensure_state_dir() in codewhale-config - Config: resolve_config_path supports CODEWHALE_CONFIG_PATH env, default_config_path prefers ~/.codewhale/config.toml - Project overlay: checks .codewhale/config.toml before .deepseek/ - Sessions: default_sessions_dir uses resolve_state_dir with fallback - Workspace trust: writes to CodeWhale home via ensure_state_dir - Init: ensure_deepseek_gitignored adds both .codewhale/ and .deepseek/ - .gitignore: adds .codewhale/ #2010: session artifact hygiene - /save without path now writes to managed sessions dir instead of cwd - Boot-time session prune via cleanup_old_sessions (MAX_SESSIONS=50) - sessions_dir() public accessor for checkpoint path resolution Fix: load_recent_checkpoint now uses manager.sessions_dir() instead of hardcoding ~/.deepseek/sessions/checkpoints/
This commit is contained in:
@@ -64,6 +64,7 @@ project_overhaul_prompt.md
|
||||
.wrangler/
|
||||
|
||||
# Local runtime state
|
||||
.codewhale/
|
||||
.deepseek/
|
||||
**/session_*.json
|
||||
*.db
|
||||
|
||||
Generated
+1
@@ -977,6 +977,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"codewhale-config",
|
||||
"codewhale-secrets",
|
||||
"codewhale-tools",
|
||||
"colored",
|
||||
|
||||
@@ -1120,15 +1120,21 @@ fn merge_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfi
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a project-level config from `$WORKSPACE/.deepseek/config.toml`.
|
||||
/// Returns `None` if the file doesn't exist or can't be parsed.
|
||||
/// Load a project-level config from the workspace.
|
||||
///
|
||||
/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
|
||||
/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
|
||||
/// Returns `None` if neither file exists or can't be parsed.
|
||||
pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
|
||||
let path = workspace.join(".deepseek").join(CONFIG_FILE_NAME);
|
||||
if !path.exists() {
|
||||
return None;
|
||||
for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
|
||||
let path = workspace.join(dir).join(CONFIG_FILE_NAME);
|
||||
if path.exists() {
|
||||
if let Ok(raw) = fs::read_to_string(&path) {
|
||||
return toml::from_str(&raw).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = fs::read_to_string(&path).ok()?;
|
||||
toml::from_str(&raw).ok()
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
|
||||
@@ -1442,9 +1448,80 @@ pub fn default_secrets() -> &'static Secrets {
|
||||
})
|
||||
}
|
||||
|
||||
// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
|
||||
//
|
||||
// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
|
||||
// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
|
||||
// New installs write to ~/.codewhale/. Existing installs with only
|
||||
// ~/.deepseek/ continue working without data loss.
|
||||
|
||||
/// Canonical CodeWhale app directory name under $HOME.
|
||||
pub const CODEWHALE_APP_DIR: &str = ".codewhale";
|
||||
|
||||
/// Legacy DeepSeek-branded app directory name (compatibility fallback).
|
||||
pub const LEGACY_APP_DIR: &str = ".deepseek";
|
||||
|
||||
/// Resolve the primary CodeWhale home directory.
|
||||
///
|
||||
/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
|
||||
/// `$HOME/.codewhale`. This is the write target for new product state.
|
||||
pub fn codewhale_home() -> Result<PathBuf> {
|
||||
if let Ok(val) = std::env::var("CODEWHALE_HOME") {
|
||||
let trimmed = val.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
let home = dirs::home_dir().context("failed to resolve home directory")?;
|
||||
Ok(home.join(CODEWHALE_APP_DIR))
|
||||
}
|
||||
|
||||
/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
|
||||
///
|
||||
/// Always returns the legacy path regardless of whether it exists.
|
||||
pub fn legacy_deepseek_home() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("failed to resolve home directory")?;
|
||||
Ok(home.join(LEGACY_APP_DIR))
|
||||
}
|
||||
|
||||
/// Resolve a state subdirectory, preferring the CodeWhale root if
|
||||
/// it already exists, otherwise falling back to the legacy root.
|
||||
///
|
||||
/// This is the read-path resolver: it returns the primary path when
|
||||
/// migration has occurred or on a fresh install, but keeps reading
|
||||
/// from the legacy path for users who haven't migrated yet.
|
||||
pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
|
||||
let primary = codewhale_home()?.join(subdir);
|
||||
if primary.exists() {
|
||||
return Ok(primary);
|
||||
}
|
||||
let legacy = legacy_deepseek_home()?.join(subdir);
|
||||
if legacy.exists() {
|
||||
return Ok(legacy);
|
||||
}
|
||||
// Neither exists — return primary for first-write creation.
|
||||
Ok(primary)
|
||||
}
|
||||
|
||||
/// Ensure a state subdirectory exists under the primary CodeWhale root,
|
||||
/// creating it if necessary. This is the write-path resolver.
|
||||
pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
|
||||
let dir = codewhale_home()?.join(subdir);
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("failed to create {}/", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
|
||||
let path = if let Some(path) = explicit {
|
||||
path
|
||||
} else if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
PathBuf::from(trimmed)
|
||||
} else {
|
||||
return default_config_path();
|
||||
}
|
||||
} else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
@@ -1459,8 +1536,18 @@ pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("failed to resolve home directory for config path")?;
|
||||
Ok(home.join(".deepseek").join(CONFIG_FILE_NAME))
|
||||
// Prefer ~/.codewhale/config.toml when it exists (fresh install or
|
||||
// migrated), otherwise fall back to ~/.deepseek/config.toml.
|
||||
let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
|
||||
if primary.exists() {
|
||||
return Ok(primary);
|
||||
}
|
||||
let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
|
||||
if legacy.exists() {
|
||||
return Ok(legacy);
|
||||
}
|
||||
// Neither exists — return primary so first write creates it there.
|
||||
Ok(primary)
|
||||
}
|
||||
|
||||
fn parse_bool(raw: &str) -> Result<bool> {
|
||||
|
||||
@@ -27,6 +27,7 @@ path = "src/bin/deepseek_tui_legacy_shim.rs"
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
codewhale-config = { path = "../config", version = "0.8.43" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.43" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.43" }
|
||||
schemaui = { version = "0.12.0", default-features = false, optional = true }
|
||||
|
||||
@@ -35,9 +35,9 @@ pub fn init(app: &mut App) -> CommandResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// If `workspace` is inside a git repository, ensure `.deepseek/` is listed
|
||||
/// in the nearest `.gitignore` so that snapshots, instructions, and other
|
||||
/// workspace-local state are not accidentally committed.
|
||||
/// If `workspace` is inside a git repository, ensure `.codewhale/` and
|
||||
/// `.deepseek/` are listed in the nearest `.gitignore` so that snapshots,
|
||||
/// instructions, and other workspace-local state are not accidentally committed.
|
||||
fn ensure_deepseek_gitignored(workspace: &Path) {
|
||||
// Only act if this workspace is a git repo.
|
||||
if !workspace.join(".git").exists() {
|
||||
@@ -45,24 +45,27 @@ fn ensure_deepseek_gitignored(workspace: &Path) {
|
||||
}
|
||||
|
||||
let gitignore = workspace.join(".gitignore");
|
||||
let entry = ".deepseek/";
|
||||
let entries = [".codewhale/", ".deepseek/"];
|
||||
|
||||
// Read existing contents (if any) and check whether the entry is already present.
|
||||
// Check both with and without trailing slash to catch variants like
|
||||
// ".deepseek" and ".deepseek/".
|
||||
if let Ok(existing) = std::fs::read_to_string(&gitignore) {
|
||||
// Read existing contents once.
|
||||
let existing = std::fs::read_to_string(&gitignore).unwrap_or_default();
|
||||
let mut missing: Vec<&str> = Vec::new();
|
||||
for entry in entries {
|
||||
let entry_no_slash = entry.trim_end_matches('/');
|
||||
if existing.lines().any(|line| {
|
||||
let already_ignored = existing.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed == entry || trimmed == entry_no_slash
|
||||
}) {
|
||||
return; // already ignored
|
||||
});
|
||||
if !already_ignored {
|
||||
missing.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Append the entry. If .gitignore doesn't exist yet, create it with a header.
|
||||
// Ensure there's a trailing newline before our entry to avoid joining with
|
||||
// a previous unterminated line.
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Append missing entries. If .gitignore doesn't exist yet, create it.
|
||||
use std::io::Write;
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
@@ -73,7 +76,6 @@ fn ensure_deepseek_gitignored(workspace: &Path) {
|
||||
if let Ok(meta) = file.metadata()
|
||||
&& meta.len() > 0
|
||||
{
|
||||
// Read last byte to check for trailing newline.
|
||||
if let Ok(mut f) = std::fs::File::open(&gitignore) {
|
||||
use std::io::Seek;
|
||||
if f.seek(std::io::SeekFrom::End(-1)).is_ok() {
|
||||
@@ -84,7 +86,9 @@ fn ensure_deepseek_gitignored(workspace: &Path) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = writeln!(file, "{entry}");
|
||||
for entry in &missing {
|
||||
let _ = writeln!(file, "{entry}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,21 @@ use crate::tui::session_picker::SessionPickerView;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Save session to file
|
||||
/// Save session to file.
|
||||
///
|
||||
/// When an explicit path is given, the session is exported there
|
||||
/// (user-visible explicit export). Without a path, v0.8.44 saves
|
||||
/// into the managed session directory (`~/.codewhale/sessions`
|
||||
/// or legacy `~/.deepseek/sessions`) so repo-local `session_*.json`
|
||||
/// artifacts are no longer created by default.
|
||||
pub fn save(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
let save_path = if let Some(p) = path {
|
||||
PathBuf::from(p)
|
||||
} else {
|
||||
let dir = crate::session_manager::default_sessions_dir()
|
||||
.unwrap_or_else(|_| app.workspace.clone());
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
PathBuf::from(format!("session_{timestamp}.json"))
|
||||
dir.join(format!("session_{timestamp}.json"))
|
||||
};
|
||||
|
||||
let messages = app.api_messages.clone();
|
||||
|
||||
@@ -4452,10 +4452,8 @@ fn load_recent_checkpoint(
|
||||
) -> Option<(session_manager::SavedSession, std::time::Duration)> {
|
||||
let session = manager.load_checkpoint().ok().flatten()?;
|
||||
|
||||
let home = dirs::home_dir()?;
|
||||
let checkpoint_path = home
|
||||
.join(".deepseek")
|
||||
.join("sessions")
|
||||
let checkpoint_path = manager
|
||||
.sessions_dir()
|
||||
.join("checkpoints")
|
||||
.join("latest.json");
|
||||
let metadata = std::fs::metadata(&checkpoint_path).ok()?;
|
||||
@@ -4745,6 +4743,12 @@ async fn run_interactive(
|
||||
),
|
||||
}
|
||||
|
||||
// v0.8.44: prune managed sessions on boot to prevent unbounded growth.
|
||||
// Keeps at most MAX_SESSIONS (50) recent sessions; non-fatal on error.
|
||||
if let Ok(manager) = session_manager::SessionManager::default_location() {
|
||||
let _ = manager.cleanup_old_sessions();
|
||||
}
|
||||
|
||||
tui::run_tui(
|
||||
config,
|
||||
tui::TuiOptions {
|
||||
|
||||
@@ -242,11 +242,16 @@ impl SessionManager {
|
||||
Ok(Self { sessions_dir })
|
||||
}
|
||||
|
||||
/// Create a `SessionManager` using the default location (~/.deepseek/sessions)
|
||||
/// Create a `SessionManager` using the default location.
|
||||
pub fn default_location() -> std::io::Result<Self> {
|
||||
Self::new(default_sessions_dir()?)
|
||||
}
|
||||
|
||||
/// Return the resolved sessions directory path.
|
||||
pub fn sessions_dir(&self) -> &Path {
|
||||
&self.sessions_dir
|
||||
}
|
||||
|
||||
/// Save a session to disk using atomic write (temp file + fsync + rename).
|
||||
pub fn save_session(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
|
||||
let path = self.validated_session_path(&session.metadata.id)?;
|
||||
@@ -478,8 +483,8 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up old sessions to stay within `MAX_SESSIONS` limit
|
||||
fn cleanup_old_sessions(&self) -> std::io::Result<()> {
|
||||
/// Clean up old sessions to stay within `MAX_SESSIONS` limit.
|
||||
pub fn cleanup_old_sessions(&self) -> std::io::Result<()> {
|
||||
let sessions = self.list_sessions()?;
|
||||
|
||||
if sessions.len() > MAX_SESSIONS {
|
||||
@@ -607,12 +612,14 @@ fn is_git_metadata_entry(path: &Path) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resolve the default session directory path (`~/.deepseek/sessions`).
|
||||
/// Resolve the default session directory path.
|
||||
///
|
||||
/// v0.8.44: prefers `~/.codewhale/sessions`, falls back to
|
||||
/// `~/.deepseek/sessions` for existing installs.
|
||||
pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
|
||||
let home = dirs::home_dir().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found")
|
||||
})?;
|
||||
Ok(home.join(".deepseek").join("sessions"))
|
||||
codewhale_config::resolve_state_dir("sessions").map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Prune snapshots older than `max_age` for `workspace`.
|
||||
|
||||
@@ -158,7 +158,9 @@ fn canonicalize_or_keep(path: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
fn trust_file_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join(TRUST_FILE_NAME))
|
||||
codewhale_config::ensure_state_dir(".")
|
||||
.ok()
|
||||
.map(|dir| dir.join(TRUST_FILE_NAME))
|
||||
}
|
||||
|
||||
fn read_trust_file_at(path: &Path) -> Result<TrustFile> {
|
||||
|
||||
Reference in New Issue
Block a user