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:
Hunter Bown
2026-05-24 15:04:06 -05:00
parent 25ce4f5970
commit a3f50fe851
9 changed files with 155 additions and 40 deletions
+1
View File
@@ -64,6 +64,7 @@ project_overhaul_prompt.md
.wrangler/
# Local runtime state
.codewhale/
.deepseek/
**/session_*.json
*.db
Generated
+1
View File
@@ -977,6 +977,7 @@ dependencies = [
"chrono",
"clap",
"clap_complete",
"codewhale-config",
"codewhale-secrets",
"codewhale-tools",
"colored",
+96 -9
View File
@@ -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> {
+1
View File
@@ -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 }
+20 -16
View File
@@ -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}");
}
}
}
+10 -2
View File
@@ -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();
+8 -4
View File
@@ -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 {
+15 -8
View File
@@ -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`.
+3 -1
View File
@@ -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> {