Add subdirectory file discovery to arc-devcontainer

When neither .devcontainer/devcontainer.json nor .devcontainer.json
exists, scan .devcontainer/ for subdirectories containing a
devcontainer.json. Subdirectories are sorted alphabetically and the
first match is used. Standard locations still take priority.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 00:42:34 -05:00
parent d437f038d0
commit 62b4e9df74
7 changed files with 94 additions and 1 deletions

View file

@ -357,17 +357,57 @@ impl DevcontainerResolver {
return Ok((path.to_path_buf(), parsed));
}
// Subdirectory format: scan .devcontainer/ for subdirs containing devcontainer.json
let devcontainer_dir = path.join(".devcontainer");
if devcontainer_dir.is_dir() {
let mut subdirs: Vec<PathBuf> = std::fs::read_dir(&devcontainer_dir)
.map_err(|source| DevcontainerError::ReadFile {
path: devcontainer_dir.clone(),
source,
})?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.path())
.filter(|dir| dir.join("devcontainer.json").exists())
.collect();
// Sort alphabetically to get deterministic first pick
subdirs.sort();
if let Some(subdir) = subdirs.first() {
let candidate = subdir.join("devcontainer.json");
let raw = std::fs::read_to_string(&candidate).map_err(|source| {
DevcontainerError::ReadFile {
path: candidate.clone(),
source,
}
})?;
let stripped = jsonc::strip_jsonc(&raw);
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
return Ok((candidate, parsed));
}
}
Err(DevcontainerError::NotFound(path.to_path_buf()))
}
fn repo_root_from_json_path<'a>(json_path: &Path, original_path: &'a Path) -> &'a Path {
// If json_path is inside .devcontainer/<subdir>/, the repo root is two levels up
// If json_path is inside .devcontainer/, the repo root is one level up
if let Some(parent) = json_path.parent() {
if parent.file_name().is_some_and(|n| n == ".devcontainer") {
if let Some(repo_root) = parent.parent() {
// Only return repo_root if it matches the original path structure
let _ = repo_root;
}
} else if let Some(grandparent) = parent.parent() {
if grandparent
.file_name()
.is_some_and(|n| n == ".devcontainer")
{
if let Some(repo_root) = grandparent.parent() {
let _ = repo_root;
}
}
}
}
original_path

View file

@ -0,0 +1,4 @@
{
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"remoteUser": "vscode"
}

View file

@ -0,0 +1,4 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"remoteUser": "alpha-user"
}

View file

@ -0,0 +1,4 @@
{
"image": "node:20",
"remoteUser": "beta-user"
}

View file

@ -0,0 +1,4 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"remoteUser": "standard-user"
}

View file

@ -0,0 +1,4 @@
{
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"remoteUser": "python-user"
}

View file

@ -126,6 +126,39 @@ async fn resolve_not_found() {
assert!(err.to_string().contains("no devcontainer.json found"));
}
#[tokio::test]
async fn resolve_subdirectory_mode() {
let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-mode"))
.await
.unwrap();
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/python:3.12"));
assert_eq!(config.remote_user.as_deref(), Some("vscode"));
assert_eq!(config.workspace_folder, "/workspaces/subdirectory-mode");
}
#[tokio::test]
async fn resolve_subdirectory_multiple_picks_alphabetical_first() {
let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-multiple"))
.await
.unwrap();
// "alpha" sorts before "beta", so alpha's config is used
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
assert_eq!(config.remote_user.as_deref(), Some("alpha-user"));
}
#[tokio::test]
async fn resolve_subdirectory_standard_wins_over_subdirs() {
let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-with-standard"))
.await
.unwrap();
// Standard .devcontainer/devcontainer.json takes priority over subdirectory format
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
assert_eq!(config.remote_user.as_deref(), Some("standard-user"));
}
#[tokio::test]
async fn generated_dockerfile_is_well_formed() {
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))