Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ hex = "0.4"
base64 = "0.22"
semver = "1"
shellexpand = "3"
glob = "0.3"
futures-util = "0.3"
serde_yaml = "0.9"

Expand Down
36 changes: 28 additions & 8 deletions coast-cli/src/commands/lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,28 @@ pub fn detect_worktree() -> Result<Option<String>> {
let cwd = std::env::current_dir().context("Failed to get current directory")?;

if let Ok((project_root, worktree_dirs)) = find_project_root_and_worktree_dirs(&cwd) {
use coast_core::coastfile::Coastfile;

for dir in &worktree_dirs {
let resolved =
coast_core::coastfile::Coastfile::resolve_worktree_dir(&project_root, dir);
if let Some(name) = detect_worktree_from_paths(&cwd, &resolved)? {
return Ok(Some(name));
if Coastfile::is_glob_pattern(dir) {
let expanded = Coastfile::resolve_external_worktree_dirs_expanded(
&worktree_dirs,
&project_root,
);
for ext_dir in &expanded {
if ext_dir.raw_pattern == *dir {
if let Some(name) =
detect_worktree_from_paths(&cwd, &ext_dir.resolved_path)?
{
return Ok(Some(name));
}
}
}
} else {
let resolved = Coastfile::resolve_worktree_dir(&project_root, dir);
if let Some(name) = detect_worktree_from_paths(&cwd, &resolved)? {
return Ok(Some(name));
}
}
}
}
Expand Down Expand Up @@ -187,10 +204,13 @@ fn detect_worktree_via_git(cwd: &Path) -> Result<Option<String>> {
let stdout = String::from_utf8_lossy(&output.stdout);

let worktree_dirs = load_worktree_dirs_from_project(&real_project_root);
let external_dirs: Vec<std::path::PathBuf> = worktree_dirs
.iter()
.filter(|d| coast_core::coastfile::Coastfile::is_external_worktree_dir(d))
.map(|d| coast_core::coastfile::Coastfile::resolve_worktree_dir(&real_project_root, d))
let external_dirs: Vec<std::path::PathBuf> =
coast_core::coastfile::Coastfile::resolve_external_worktree_dirs_expanded(
&worktree_dirs,
&real_project_root,
)
.into_iter()
.map(|d| d.resolved_path)
.collect();

let mut current_path: Option<std::path::PathBuf> = None;
Expand Down
1 change: 1 addition & 0 deletions coast-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ shellexpand = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
dirs = { workspace = true }
glob = { workspace = true }

ts-rs = { workspace = true }

Expand Down
71 changes: 71 additions & 0 deletions coast-core/src/coastfile/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -762,4 +762,75 @@ impl Coastfile {
pub fn external_mount_path(index: usize) -> String {
format!("{EXTERNAL_WORKTREE_MOUNT_PREFIX}/{index}")
}

/// Returns `true` if a worktree dir path contains glob metacharacters (`*`, `?`, `[`).
pub fn is_glob_pattern(dir: &str) -> bool {
dir.contains('*') || dir.contains('?') || dir.contains('[')
}

/// Resolve all external worktree dirs, expanding glob patterns.
///
/// Non-glob entries keep their original `worktree_dirs` index as the mount
/// index (backward compatible). For glob entries the first match reuses the
/// original index; additional matches are allocated sequentially starting
/// from `worktree_dirs.len()`.
pub fn resolve_external_worktree_dirs_expanded(
worktree_dirs: &[String],
project_root: &Path,
) -> Vec<ResolvedExternalDir> {
let mut results = Vec::new();
let mut overflow_index = worktree_dirs.len();

for (idx, dir) in worktree_dirs.iter().enumerate() {
if !Self::is_external_worktree_dir(dir) {
continue;
}
let resolved = Self::resolve_worktree_dir(project_root, dir);
let resolved_str = resolved.to_string_lossy().to_string();

if Self::is_glob_pattern(&resolved_str) {
let mut matches: Vec<PathBuf> = glob::glob(&resolved_str)
.into_iter()
.flatten()
.filter_map(std::result::Result::ok)
.filter(|p| p.is_dir())
.collect();
matches.sort();

for (i, matched_path) in matches.into_iter().enumerate() {
let mount_index = if i == 0 {
idx
} else {
let mi = overflow_index;
overflow_index += 1;
mi
};
results.push(ResolvedExternalDir {
mount_index,
raw_pattern: dir.clone(),
resolved_path: matched_path,
});
}
} else {
results.push(ResolvedExternalDir {
mount_index: idx,
raw_pattern: dir.clone(),
resolved_path: resolved,
});
}
}

results
}
}

/// A resolved external worktree directory, possibly expanded from a glob pattern.
#[derive(Debug, Clone)]
pub struct ResolvedExternalDir {
/// Index used for the container mount path (`/host-external-wt/{mount_index}`).
pub mount_index: usize,
/// The original pattern string from the Coastfile (e.g. `~/.shep/repos/*/wt`).
pub raw_pattern: String,
/// The fully resolved absolute path on the host.
pub resolved_path: PathBuf,
}
98 changes: 98 additions & 0 deletions coast-core/src/coastfile/tests_parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,104 @@ fn test_external_mount_path() {
assert_eq!(Coastfile::external_mount_path(3), "/host-external-wt/3");
}

// ---------------------------------------------------------------------------
// Glob pattern tests
// ---------------------------------------------------------------------------

#[test]
fn test_is_glob_pattern() {
assert!(Coastfile::is_glob_pattern("~/.shep/repos/*/wt"));
assert!(Coastfile::is_glob_pattern("/foo/ba?/baz"));
assert!(Coastfile::is_glob_pattern("/foo/[abc]/bar"));
assert!(!Coastfile::is_glob_pattern("~/.codex/worktrees"));
assert!(!Coastfile::is_glob_pattern(".worktrees"));
assert!(!Coastfile::is_glob_pattern("/absolute/path"));
}

#[test]
fn test_resolve_external_worktree_dirs_expanded_no_globs() {
let dir = tempfile::tempdir().unwrap();
let dirs = vec![".worktrees".to_string(), "~/.codex/worktrees".to_string()];
let result = Coastfile::resolve_external_worktree_dirs_expanded(&dirs, dir.path());
assert_eq!(result.len(), 1);
assert_eq!(result[0].mount_index, 1);
assert_eq!(result[0].raw_pattern, "~/.codex/worktrees");
}

#[test]
fn test_resolve_external_worktree_dirs_expanded_glob_with_matches() {
let dir = tempfile::tempdir().unwrap();
let ext = dir.path().join("ext");
std::fs::create_dir_all(ext.join("aaa").join("wt")).unwrap();
std::fs::create_dir_all(ext.join("bbb").join("wt")).unwrap();
std::fs::create_dir_all(ext.join("ccc")).unwrap(); // no "wt" subdir

let pattern = format!("{}/*/wt", ext.display());
let dirs = vec![".worktrees".to_string(), pattern.clone()];
let result = Coastfile::resolve_external_worktree_dirs_expanded(&dirs, dir.path());

assert_eq!(result.len(), 2, "should match aaa/wt and bbb/wt");
assert_eq!(
result[0].mount_index, 1,
"first match reuses original index"
);
assert_eq!(
result[1].mount_index, 2,
"second match overflows to dirs.len()"
);
assert!(result[0].resolved_path.ends_with("aaa/wt"));
assert!(result[1].resolved_path.ends_with("bbb/wt"));
assert_eq!(result[0].raw_pattern, pattern);
}

#[test]
fn test_resolve_external_worktree_dirs_expanded_glob_no_matches() {
let dir = tempfile::tempdir().unwrap();
let pattern = format!("{}/nonexistent/*/wt", dir.path().display());
let dirs = vec![".worktrees".to_string(), pattern];
let result = Coastfile::resolve_external_worktree_dirs_expanded(&dirs, dir.path());
assert!(result.is_empty(), "no matches should produce empty result");
}

#[test]
fn test_resolve_external_worktree_dirs_expanded_preserves_non_glob_index() {
let dir = tempfile::tempdir().unwrap();
let ext = dir.path().join("ext");
std::fs::create_dir_all(ext.join("hash1").join("wt")).unwrap();

let glob_pattern = format!("{}/*/wt", ext.display());
let dirs = vec![
".worktrees".to_string(), // index 0 (local)
"~/.codex/worktrees".to_string(), // index 1 (external, non-glob)
glob_pattern, // index 2 (external, glob)
"/some/literal/path".to_string(), // index 3 (external, non-glob)
];
let result = Coastfile::resolve_external_worktree_dirs_expanded(&dirs, dir.path());

assert_eq!(result.len(), 3);
assert_eq!(result[0].mount_index, 1, "codex keeps index 1");
assert_eq!(result[1].mount_index, 2, "glob first match keeps index 2");
assert_eq!(result[2].mount_index, 3, "literal keeps index 3");
}

#[test]
fn test_resolve_external_worktree_dirs_expanded_sorted_deterministic() {
let dir = tempfile::tempdir().unwrap();
let ext = dir.path().join("repos");
std::fs::create_dir_all(ext.join("zzz").join("wt")).unwrap();
std::fs::create_dir_all(ext.join("aaa").join("wt")).unwrap();
std::fs::create_dir_all(ext.join("mmm").join("wt")).unwrap();

let pattern = format!("{}/*/wt", ext.display());
let dirs = vec![pattern];
let result = Coastfile::resolve_external_worktree_dirs_expanded(&dirs, dir.path());

assert_eq!(result.len(), 3);
assert!(result[0].resolved_path.ends_with("aaa/wt"), "sorted first");
assert!(result[1].resolved_path.ends_with("mmm/wt"), "sorted second");
assert!(result[2].resolved_path.ends_with("zzz/wt"), "sorted third");
}

#[test]
fn test_worktree_dir_string_or_vec_compat() {
let dir = tempfile::tempdir().unwrap();
Expand Down
7 changes: 3 additions & 4 deletions coast-daemon/src/api/query/project_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,9 @@ fn load_external_worktree_dirs(project: &str, project_root: &std::path::Path) ->
use coast_core::coastfile::Coastfile;

let worktree_dirs = load_worktree_dirs_from_live_or_cached(project, project_root);
worktree_dirs
.iter()
.filter(|d| Coastfile::is_external_worktree_dir(d))
.map(|d| Coastfile::resolve_worktree_dir(project_root, d))
Coastfile::resolve_external_worktree_dirs_expanded(&worktree_dirs, project_root)
.into_iter()
.map(|d| d.resolved_path)
.collect()
}

Expand Down
41 changes: 23 additions & 18 deletions coast-daemon/src/git_watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,31 +87,36 @@ async fn list_worktree_dirs(project_root: &Path, wt_dir_names: &[String]) -> Opt
let mut found_any = false;
let git_dir = project_root.join(".git");

let expanded_external =
Coastfile::resolve_external_worktree_dirs_expanded(wt_dir_names, project_root);

for wt_dir_name in wt_dir_names {
if Coastfile::is_external_worktree_dir(wt_dir_name) {
let resolved = Coastfile::resolve_worktree_dir(project_root, wt_dir_name);
let found = scan_external_worktree_dir(&resolved, &git_dir).await;
if !found.is_empty() {
found_any = true;
names.extend(found);
}
} else {
let wt_path = project_root.join(wt_dir_name);
let Ok(mut entries) = tokio::fs::read_dir(&wt_path).await else {
continue;
};
found_any = true;
while let Ok(Some(entry)) = entries.next_entry().await {
if let Ok(ft) = entry.file_type().await {
if ft.is_dir() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
continue; // handled via expanded_external below
}
let wt_path = project_root.join(wt_dir_name);
let Ok(mut entries) = tokio::fs::read_dir(&wt_path).await else {
continue;
};
found_any = true;
while let Ok(Some(entry)) = entries.next_entry().await {
if let Ok(ft) = entry.file_type().await {
if ft.is_dir() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
}
}
}

for ext_dir in &expanded_external {
let found = scan_external_worktree_dir(&ext_dir.resolved_path, &git_dir).await;
if !found.is_empty() {
found_any = true;
names.extend(found);
}
}
if !found_any {
return None;
}
Expand Down
Loading