🥇 export from upstream (b86fc6f)
This commit is contained in:
@@ -31,3 +31,4 @@ __pycache__
|
||||
settings.local.json
|
||||
.claude/worktrees/
|
||||
CLAUDE.local.md
|
||||
.sandcage.local.yml
|
||||
|
||||
@@ -10,8 +10,6 @@ packages:
|
||||
|
||||
toolchains:
|
||||
rust: stable
|
||||
mounts:
|
||||
- ~/.ssh:/home/agent/.ssh:ro
|
||||
|
||||
# mounts:
|
||||
# - ~/.ssh:/home/agent/.ssh:ro
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user