🥇 export from upstream (b86fc6f)

This commit is contained in:
2026-05-23 19:29:11 +02:00
parent 7ae291679b
commit 91a62fdb85
7 changed files with 47 additions and 26 deletions
+1
View File
@@ -31,3 +31,4 @@ __pycache__
settings.local.json
.claude/worktrees/
CLAUDE.local.md
.sandcage.local.yml
-2
View File
@@ -10,8 +10,6 @@ packages:
toolchains:
rust: stable
mounts:
- ~/.ssh:/home/agent/.ssh:ro
# mounts:
# - ~/.ssh:/home/agent/.ssh:ro
+18 -10
View File
@@ -225,13 +225,13 @@ pub fn load_trusted_projects(global_config_path: &Path) -> Vec<PathBuf> {
/// 1. Compiled defaults (all `None`)
/// 2. Global config from `global_config_path` (TOML, e.g. `~/.sandcage/config.toml`)
/// 3. Project config from `project_config_path` (YAML, e.g. `.sandcage.yml`)
/// 4. Local project config from `local_config_path` (YAML, e.g. `.sandcage.local.yml`)
///
/// Later layers win: project values override global values, which override defaults.
///
/// Pass `None` for either path to skip that layer (e.g. when the file doesn't exist).
/// Later layers win. Pass `None` for any path to skip that layer.
pub fn resolve_config(
global_config_path: Option<&Path>,
project_config_path: Option<&Path>,
local_config_path: Option<&Path>,
) -> Result<SandcageConfig> {
// Start with compiled defaults — all fields None.
let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default()));
@@ -249,6 +249,13 @@ pub fn resolve_config(
figment = figment.merge(Serialized::defaults(project_cfg));
}
if let Some(local_path) = local_config_path
&& local_path.exists()
{
let local_cfg = load(local_path)?;
figment = figment.merge(Serialized::defaults(local_cfg));
}
figment
.extract()
.map_err(|e| ConfigError::MergeFailed(Box::new(e)))
@@ -442,7 +449,7 @@ justfile: ./project.justfile
#[test]
fn resolve_defaults_only_all_none() {
let cfg = resolve_config(None, None).expect("resolve with no files");
let cfg = resolve_config(None, None, None).expect("resolve with no files");
assert!(cfg.env.is_none());
assert!(cfg.packages.is_none());
assert!(cfg.toolchains.is_none());
@@ -462,7 +469,7 @@ EDITOR = "vim"
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{toml_content}").expect("write global tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), None).expect("resolve global only");
let cfg = resolve_config(Some(global_tmp.path()), None, None).expect("resolve global only");
assert_eq!(cfg.shell.as_deref(), Some("zsh"));
let env = cfg.env.expect("env should be Some from global");
assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim"));
@@ -475,7 +482,7 @@ EDITOR = "vim"
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{yaml_content}").expect("write project tmpfile");
let cfg = resolve_config(None, Some(project_tmp.path())).expect("resolve project only");
let cfg = resolve_config(None, Some(project_tmp.path()), None).expect("resolve project only");
assert_eq!(cfg.shell.as_deref(), Some("bash"));
let pkgs = cfg.packages.expect("packages should be Some");
assert_eq!(pkgs, vec!["ripgrep"]);
@@ -494,7 +501,7 @@ EDITOR = "vim"
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()))
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None)
.expect("resolve both");
assert_eq!(cfg.shell.as_deref(), Some("bash"), "project shell should override global");
}
@@ -512,7 +519,7 @@ EDITOR = "vim"
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()))
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None)
.expect("resolve partial overlap");
assert_eq!(cfg.shell.as_deref(), Some("zsh"), "shell should come from global");
let env = cfg.env.expect("env should be Some from global");
@@ -527,6 +534,7 @@ EDITOR = "vim"
let cfg = resolve_config(
Some(Path::new("/nonexistent/config.toml")),
Some(Path::new("/nonexistent/.sandcage.yml")),
None,
)
.expect("nonexistent files should not error");
assert!(cfg.shell.is_none());
@@ -567,7 +575,7 @@ agent_args:
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()))
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None)
.expect("resolve");
let agent_args = cfg.agent_args.expect("agent_args should be Some");
let claude_args = agent_args.get("claude").expect("claude args");
@@ -631,7 +639,7 @@ ssh_keys:
write!(global_tmp, "{global_toml}").expect("write global tmpfile");
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path())).expect("resolve");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None).expect("resolve");
assert_eq!(cfg.ssh_mode.as_deref(), Some("volume"));
}
}
+1 -1
View File
@@ -492,7 +492,7 @@ pub fn build_images(force: bool) -> Result<()> {
let project_config = project_dir.join(".sandcage.yml");
// Global overrides are always trusted.
let global_cfg = crate::config::resolve_config(Some(&global_config), None)
let global_cfg = crate::config::resolve_config(Some(&global_config), None, None)
.unwrap_or_default();
let mut overrides = global_cfg.dockerfiles.unwrap_or_default();
+21
View File
@@ -72,6 +72,25 @@ pub fn preseed_with_home(home: &Path) -> Result<()> {
Ok(())
}
fn ensure_gitignore_entry(project_dir: &Path, entry: &str) -> Result<()> {
let gitignore_path = project_dir.join(".gitignore");
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
if content.lines().any(|line| line.trim() == entry) {
return Ok(());
}
let separator = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" };
let addition = format!("{separator}{entry}\n");
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore_path)
.and_then(|mut f| {
use std::io::Write;
f.write_all(addition.as_bytes())
})
.map_err(|e| InitError::WriteFileFailed(gitignore_path, e))
}
fn create_dir_all(path: &Path) -> Result<()> {
std::fs::create_dir_all(path)
.map_err(|e| InitError::CreateDirFailed(path.to_path_buf(), e))
@@ -179,6 +198,8 @@ pub fn scaffold_project(path: &Path) -> Result<()> {
std::fs::write(&config_path, &yaml)
.map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?;
ensure_gitignore_entry(path, ".sandcage.local.yml")?;
println!(
"sandcage: initialised {} (detected ecosystem: {})",
config_path.display(),
+2
View File
@@ -122,9 +122,11 @@ fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_a
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
let global_config = home.join(".sandcage").join("config.toml");
let project_config = workspace.join(".sandcage.yml");
let local_config = workspace.join(".sandcage.local.yml");
let cfg = config::resolve_config(
Some(&global_config),
Some(&project_config),
Some(&local_config),
)?;
eprintln!("sandcage: service \u{2192} {service}");
+4 -13
View File
@@ -581,16 +581,12 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu
}
// Determine config target
let (config_path, is_global) = if global {
(home.join(".sandcage").join("config.toml"), true)
let (config_path, config_label, is_global) = if global {
(home.join(".sandcage").join("config.toml"), "~/.sandcage/config.toml", true)
} else {
let cwd = std::env::current_dir()
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
let project_config = cwd.join(".sandcage.yml");
if !project_config.exists() {
return Err(SetupError::NoProjectConfig);
}
(project_config, false)
(cwd.join(".sandcage.local.yml"), ".sandcage.local.yml", false)
};
// Write config
@@ -608,11 +604,6 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu
eprintln!("sandcage: populating SSH volume...");
populate_ssh_volume(&selected_entries, &ssh_blocks, include_known_hosts)?;
let config_label = if is_global {
"~/.sandcage/config.toml"
} else {
".sandcage.yml"
};
eprintln!("sandcage: SSH keys copied to volume 'sandcage-ssh'");
eprintln!("sandcage: configuration saved to {config_label}");
@@ -627,7 +618,7 @@ fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> {
} else {
let cwd = std::env::current_dir()
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
cwd.join(".sandcage.yml")
cwd.join(".sandcage.local.yml")
};
if !config_path.exists() {