Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/forge_config/src/legacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use serde::Deserialize;

use crate::{ForgeConfig, ModelConfig};

/// Intermediate representation of the legacy `~/forge/.config.json` format.
/// Intermediate representation of the legacy `~/.forge/.config.json` format.
///
/// This format stores the active provider as a top-level string and models as
/// a map from provider ID to model ID, which differs from the TOML config's
Expand Down Expand Up @@ -35,7 +35,7 @@ struct LegacyModelRef {
}

impl LegacyConfig {
/// Reads the legacy `~/forge/.config.json` file at `path`, parses it, and
/// Reads the legacy `~/.forge/.config.json` file at `path`, parses it, and
/// returns the equivalent TOML representation as a [`String`].
///
/// Because every field in [`ForgeConfig`] is `Option`, fields not covered
Expand Down
6 changes: 4 additions & 2 deletions crates/forge_config/src/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ impl ConfigReader {
Self::base_path().join(".forge.toml")
}

/// Returns the base directory for all Forge config files (`~/forge`).
/// Returns the base directory for all Forge config files (`~/.forge`).
pub fn base_path() -> PathBuf {
dirs::home_dir().unwrap_or(PathBuf::from(".")).join("forge")
dirs::config_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")))
.join("forge")
}

/// Adds the provided TOML string as a config source without touching the
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_domain/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ impl Environment {
self.base_path.join("cache")
}

/// Returns the global skills directory path (~/forge/skills)
/// Returns the global skills directory path (~/.forge/skills)
pub fn global_skills_path(&self) -> PathBuf {
self.base_path.join("skills")
}
Expand Down
128 changes: 123 additions & 5 deletions crates/forge_infra/src/env.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,79 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};

use forge_app::EnvironmentInfra;
use forge_config::{ConfigReader, ForgeConfig, ModelConfig};
use forge_domain::{ConfigOperation, Environment};
use tracing::{debug, error};
use tracing::{debug, error, warn};

static BASE_PATH_MIGRATION: OnceLock<()> = OnceLock::new();

/// Returns the XDG config directory path, falling back to home directory.
fn xdg_config_dir() -> PathBuf {
dirs::config_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")))
}

fn old_base_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(xdg_config_dir)
.join("forge")
}
Comment on lines +17 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration fallback logic is incorrect. When dirs::home_dir() returns None, this falls back to xdg_config_dir(), making old_base_path() return ~/.config/forge (same as new path). This breaks migration detection since old and new paths would be identical.

fn old_base_path() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join("forge")
}
Suggested change
fn old_base_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(xdg_config_dir)
.join("forge")
}
fn old_base_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("forge")
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


fn new_base_path() -> PathBuf {
xdg_config_dir().join("forge")
}

fn migrate_base_path_paths(old_path: &PathBuf, new_path: &PathBuf) -> std::io::Result<bool> {
if new_path.exists() {
if old_path.exists() {
warn!(
old_path = %old_path.display(),
new_path = %new_path.display(),
"Both legacy and current Forge base directories exist; using current path"
);
}
return Ok(false);
}

if !old_path.exists() {
return Ok(false);
}

// Create parent directories if they don't exist (handles XDG config_dir case)
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}

std::fs::rename(old_path, new_path)?;
Ok(true)
}

fn migrate_base_path() {
BASE_PATH_MIGRATION.get_or_init(|| {
let old_path = old_base_path();
let new_path = new_base_path();

match migrate_base_path_paths(&old_path, &new_path) {
Ok(true) => {
debug!(
old_path = %old_path.display(),
new_path = %new_path.display(),
"Migrated Forge base directory to XDG config directory"
);
}
Ok(false) => {}
Err(error) => {
error!(
error = ?error,
old_path = %old_path.display(),
new_path = %new_path.display(),
"Failed to migrate Forge base directory; continuing with current path"
);
}
}
});
}

/// Builds a [`forge_domain::Environment`] from runtime context only.
///
Expand All @@ -14,6 +82,8 @@ use tracing::{debug, error};
/// configuration values are now accessed through
/// `EnvironmentInfra::get_config()`.
fn to_environment(cwd: PathBuf) -> Environment {
migrate_base_path();

Environment {
os: std::env::consts::OS.to_string(),
pid: std::process::id(),
Expand All @@ -24,9 +94,7 @@ fn to_environment(cwd: PathBuf) -> Environment {
} else {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
},
base_path: dirs::home_dir()
.map(|h| h.join("forge"))
.unwrap_or_else(|| PathBuf::from(".").join("forge")),
base_path: new_base_path(),
}
}

Expand Down Expand Up @@ -178,6 +246,7 @@ impl EnvironmentInfra for ForgeEnvironmentInfra {

#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;

use forge_config::ForgeConfig;
Expand All @@ -192,6 +261,55 @@ mod tests {
assert_eq!(actual.cwd, fixture_cwd);
}

#[test]
fn test_to_environment_uses_xdg_base_path() {
let fixture = PathBuf::from("/test/cwd");

let actual = to_environment(fixture);
let expected = xdg_config_dir().join("forge");

assert_eq!(actual.base_path, expected);
}

#[test]
fn test_migrate_base_path_moves_legacy_directory() {
let fixture = tempfile::tempdir().unwrap();
// XDG: old path is ~/forge, new path is ~/.config/forge
let old_home = fixture.path().join("forge");
let new_home = fixture.path().join(".config/forge");
fs::create_dir_all(&old_home).unwrap();
fs::write(old_home.join("marker.txt"), "migrated").unwrap();

let actual = migrate_base_path_paths(&old_home, &new_home).unwrap();
let expected = true;

assert_eq!(actual, expected);
assert!(!old_home.exists());
assert_eq!(
fs::read_to_string(new_home.join("marker.txt")).unwrap(),
"migrated"
);
}

#[test]
fn test_migrate_base_path_keeps_current_directory_when_both_exist() {
let fixture = tempfile::tempdir().unwrap();
// XDG: old path is ~/forge, new path is ~/.config/forge
let old_home = fixture.path().join("forge");
let new_home = fixture.path().join(".config/forge");
fs::create_dir_all(&old_home).unwrap();
fs::create_dir_all(&new_home).unwrap();
fs::write(old_home.join("old.txt"), "old").unwrap();
fs::write(new_home.join("new.txt"), "new").unwrap();

let actual = migrate_base_path_paths(&old_home, &new_home).unwrap();
let expected = false;

assert_eq!(actual, expected);
assert!(old_home.exists());
assert_eq!(fs::read_to_string(new_home.join("new.txt")).unwrap(), "new");
}

#[test]
fn test_apply_config_op_set_provider() {
use forge_domain::ProviderId;
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_main/src/built_in_commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
},
{
"command": "config-edit",
"description": "Open the global forge config file (~/forge/.forge.toml) in an editor [alias: ce]"
"description": "Open the global forge config file (~/.forge/.forge.toml) in an editor [alias: ce]"
},
{
"command": "new",
Expand Down
4 changes: 2 additions & 2 deletions crates/forge_repo/src/skill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use serde::Deserialize;

/// Repository implementation for loading skills from multiple sources:
/// 1. Built-in skills (embedded in the application)
/// 2. Global custom skills (from ~/forge/skills/ directory)
/// 2. Global custom skills (from ~/.forge/skills/ directory)
/// 3. Project-local skills (from .forge/skills/ directory in current working
/// directory)
///
Expand All @@ -24,7 +24,7 @@ use serde::Deserialize;
///
/// ## Directory Resolution
/// - **Built-in skills**: Embedded in application binary
/// - **Global skills**: `~/forge/skills/<skill-name>/SKILL.md`
/// - **Global skills**: `~/.forge/skills/<skill-name>/SKILL.md`
/// - **CWD skills**: `./.forge/skills/<skill-name>/SKILL.md` (relative to
/// current working directory)
///
Expand Down
10 changes: 5 additions & 5 deletions shell-plugin/lib/actions/config.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ function _forge_action_reasoning_effort() {

# Action handler: Set reasoning effort in global config.
# Calls `forge config set reasoning-effort <effort>` on selection,
# writing the chosen effort level permanently to ~/forge/.forge.toml.
# writing the chosen effort level permanently to ~/.forge/.forge.toml.
function _forge_action_config_reasoning_effort() {
local input_text="$1"
(
Expand Down Expand Up @@ -470,12 +470,12 @@ function _forge_action_config_edit() {
return 1
fi

local config_file="${HOME}/forge/.forge.toml"
local config_file="${HOME}/.forge/.forge.toml"

# Ensure the config directory exists
if [[ ! -d "${HOME}/forge" ]]; then
mkdir -p "${HOME}/forge" || {
_forge_log error "Failed to create ~/forge directory"
if [[ ! -d "${HOME}/.forge" ]]; then
mkdir -p "${HOME}/.forge" || {
_forge_log error "Failed to create ~/.forge directory"
Comment on lines +473 to +478
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell script is using incorrect path ${HOME}/.forge instead of ${HOME}/.config/forge. This is inconsistent with the Rust code migration and will cause the shell plugin to write config files to the wrong location.

local config_file="${HOME}/.config/forge/.forge.toml"

if [[ ! -d "${HOME}/.config/forge" ]]; then
    mkdir -p "${HOME}/.config/forge" || {
        _forge_log error "Failed to create ~/.config/forge directory"
Suggested change
local config_file="${HOME}/.forge/.forge.toml"
# Ensure the config directory exists
if [[ ! -d "${HOME}/forge" ]]; then
mkdir -p "${HOME}/forge" || {
_forge_log error "Failed to create ~/forge directory"
if [[ ! -d "${HOME}/.forge" ]]; then
mkdir -p "${HOME}/.forge" || {
_forge_log error "Failed to create ~/.forge directory"
local config_file="${HOME}/.config/forge/.forge.toml"
# Ensure the config directory exists
if [[ ! -d "${HOME}/.config/forge" ]]; then
mkdir -p "${HOME}/.config/forge" || {
_forge_log error "Failed to create ~/.config/forge directory"

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

return 1
}
fi
Expand Down
Loading