Skip to content

Commit 158df10

Browse files
committed
fix(mounts): apply workspace mount changes on restart and upsert mount targets
1 parent 46c4055 commit 158df10

7 files changed

Lines changed: 753 additions & 307 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,34 @@ occ mount clean --force
245245
# Purge bind mounts (data loss, removes config entries)
246246
occ mount clean --purge --force
247247

248+
# Mount a local project into the workspace
249+
occ mount add /Users/<username>/Desktop/opencode:/home/opencode/workspace
250+
251+
# Apply mount changes (you may be prompted to recreate the container)
252+
occ restart
253+
248254
# Factory reset host (container, volumes, mounts, config/data)
249255
occ reset host --force
250256

257+
### Workspace Mounts
258+
259+
Use `/home/opencode/workspace` when you want your host project folder to appear in the
260+
web UI's project picker and inside the container workspace.
261+
262+
Important behavior:
263+
- `/home/opencode/workspace` is a single mount target.
264+
- Adding a new mount to this same target replaces the previous mount entry.
265+
266+
Recommended workflow:
267+
```bash
268+
occ mount add /Users/<username>/Desktop/opencode:/home/opencode/workspace
269+
occ restart
270+
```
271+
272+
Verify the mount:
273+
1. Run `occ status` and check `Mounts` -> `Bind mounts` includes your host path mapped to `/home/opencode/workspace`.
274+
2. In the web UI, open the project picker and confirm your project files appear under `~/workspace`.
275+
251276
### Container Mode
252277
253278
When `occ` runs inside the opencode container, it will auto-detect this and switch to **container runtime**.

packages/cli-rust/src/commands/mount/add.rs

Lines changed: 196 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,65 @@ pub struct MountAddArgs {
2020
pub force: bool,
2121
}
2222

23+
#[derive(Debug, Clone, PartialEq)]
24+
enum MountUpsertOutcome {
25+
Added,
26+
AlreadyConfigured,
27+
Replaced { replaced_mounts: Vec<ParsedMount> },
28+
}
29+
30+
fn upsert_mount_by_target(
31+
existing_mounts: &[String],
32+
mount_spec: &str,
33+
parsed: &ParsedMount,
34+
) -> (Vec<String>, MountUpsertOutcome) {
35+
let mut retained = Vec::with_capacity(existing_mounts.len() + 1);
36+
let mut same_target = Vec::new();
37+
38+
for existing in existing_mounts {
39+
match ParsedMount::parse(existing) {
40+
Ok(existing_parsed) if existing_parsed.container_path == parsed.container_path => {
41+
same_target.push(existing_parsed);
42+
}
43+
_ => retained.push(existing.clone()),
44+
}
45+
}
46+
47+
let exact_match_count = same_target
48+
.iter()
49+
.filter(|mount| mount.host_path == parsed.host_path && mount.read_only == parsed.read_only)
50+
.count();
51+
52+
if exact_match_count == 1 && same_target.len() == 1 {
53+
return (
54+
existing_mounts.to_vec(),
55+
MountUpsertOutcome::AlreadyConfigured,
56+
);
57+
}
58+
59+
retained.push(mount_spec.to_string());
60+
if same_target.is_empty() {
61+
(retained, MountUpsertOutcome::Added)
62+
} else {
63+
(
64+
retained,
65+
MountUpsertOutcome::Replaced {
66+
replaced_mounts: same_target,
67+
},
68+
)
69+
}
70+
}
71+
72+
fn mount_to_spec(mount: &ParsedMount) -> String {
73+
let mode = if mount.read_only { ":ro" } else { "" };
74+
format!(
75+
"{}:{}{}",
76+
mount.host_path.display(),
77+
mount.container_path,
78+
mode
79+
)
80+
}
81+
2382
pub async fn cmd_mount_add(args: &MountAddArgs, quiet: bool, _verbose: u8) -> Result<()> {
2483
// Parse the mount spec
2584
let parsed = ParsedMount::parse(&args.mount_spec)?;
@@ -44,43 +103,154 @@ pub async fn cmd_mount_add(args: &MountAddArgs, quiet: bool, _verbose: u8) -> Re
44103

45104
// Load config and add mount
46105
let mut config = load_config_or_default()?;
106+
let host_str = parsed.host_path.to_string_lossy().to_string();
107+
let (updated_mounts, outcome) =
108+
upsert_mount_by_target(&config.mounts, &args.mount_spec, &parsed);
109+
config.mounts = updated_mounts;
110+
save_config(&config)?;
47111

48-
// Check for duplicate (by host path)
49-
let host_str = parsed.host_path.to_string_lossy();
50-
let already_exists = config.mounts.iter().any(|m| {
51-
ParsedMount::parse(m)
52-
.map(|p| p.host_path.to_string_lossy() == host_str)
53-
.unwrap_or(false)
54-
});
112+
if quiet {
113+
return Ok(());
114+
}
55115

56-
if already_exists {
57-
if !quiet {
116+
match outcome {
117+
MountUpsertOutcome::AlreadyConfigured => {
58118
println!(
59-
"Mount for {} already configured. Remove first with: occ mount remove {}",
119+
"Mount already configured: {} -> {}",
60120
style(&host_str).cyan(),
61-
host_str
121+
style(&parsed.container_path).cyan()
62122
);
123+
return Ok(());
124+
}
125+
MountUpsertOutcome::Added => {
126+
let mode = if parsed.read_only { "ro" } else { "rw" };
127+
println!(
128+
"Added mount: {} -> {} ({mode})",
129+
style(&host_str).cyan(),
130+
style(&parsed.container_path).cyan(),
131+
);
132+
}
133+
MountUpsertOutcome::Replaced { replaced_mounts } => {
134+
let mode = if parsed.read_only { "ro" } else { "rw" };
135+
println!(
136+
"Replaced mount target {} with {} -> {} ({mode})",
137+
style(&parsed.container_path).cyan(),
138+
style(&host_str).cyan(),
139+
style(&parsed.container_path).cyan(),
140+
);
141+
println!();
142+
println!("Previous mount(s) for this target:");
143+
for previous in &replaced_mounts {
144+
let previous_mode = if previous.read_only { "ro" } else { "rw" };
145+
println!(
146+
" - {} -> {} ({previous_mode})",
147+
previous.host_path.display(),
148+
previous.container_path
149+
);
150+
}
151+
println!();
152+
println!("If this replacement was not intended:");
153+
println!(
154+
" 1) Remove the new mount: {}",
155+
style(format!("occ mount remove {host_str}")).cyan()
156+
);
157+
if let Some(previous) = replaced_mounts.last() {
158+
println!(
159+
" 2) Re-add a previous mount: {}",
160+
style(format!("occ mount add {}", mount_to_spec(previous))).cyan()
161+
);
162+
} else {
163+
println!(
164+
" 2) Re-add your previous mount with: {}",
165+
style("occ mount add /host/path:/container/path[:ro]").cyan()
166+
);
167+
}
63168
}
64-
return Ok(());
65169
}
66170

67-
config.mounts.push(args.mount_spec.clone());
68-
save_config(&config)?;
171+
println!();
172+
println!(
173+
"{}",
174+
style("Note: Run `occ restart` to apply mount changes. If mounts changed, you will be prompted to recreate the container.").dim()
175+
);
176+
177+
Ok(())
178+
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::*;
183+
184+
#[test]
185+
fn upsert_mount_by_target_exact_same_is_noop() {
186+
let existing = vec![
187+
"/host/a:/home/opencode/workspace".to_string(),
188+
"/host/b:/home/opencode/.cache/opencode".to_string(),
189+
];
190+
let parsed = ParsedMount::parse("/host/a:/home/opencode/workspace").unwrap();
69191

70-
if !quiet {
71-
let mode = if parsed.read_only { "ro" } else { "rw" };
72-
println!(
73-
"Added mount: {} -> {} ({})",
74-
style(&host_str).cyan(),
75-
style(&parsed.container_path).cyan(),
76-
mode
192+
let (updated, outcome) =
193+
upsert_mount_by_target(&existing, "/host/a:/home/opencode/workspace", &parsed);
194+
195+
assert_eq!(updated, existing);
196+
assert_eq!(outcome, MountUpsertOutcome::AlreadyConfigured);
197+
}
198+
199+
#[test]
200+
fn upsert_mount_by_target_replaces_same_target_with_new_host() {
201+
let existing = vec![
202+
"/host/old:/home/opencode/workspace".to_string(),
203+
"/host/cache:/home/opencode/.cache/opencode".to_string(),
204+
];
205+
let parsed = ParsedMount::parse("/host/new:/home/opencode/workspace").unwrap();
206+
207+
let (updated, outcome) =
208+
upsert_mount_by_target(&existing, "/host/new:/home/opencode/workspace", &parsed);
209+
210+
assert_eq!(
211+
updated,
212+
vec![
213+
"/host/cache:/home/opencode/.cache/opencode".to_string(),
214+
"/host/new:/home/opencode/workspace".to_string(),
215+
]
77216
);
78-
println!();
79-
println!(
80-
"{}",
81-
style("Note: Restart the container for changes to take effect.").dim()
217+
assert_eq!(
218+
outcome,
219+
MountUpsertOutcome::Replaced {
220+
replaced_mounts: vec![
221+
ParsedMount::parse("/host/old:/home/opencode/workspace").unwrap(),
222+
],
223+
}
82224
);
83225
}
84226

85-
Ok(())
227+
#[test]
228+
fn upsert_mount_by_target_replaces_multiple_stale_targets() {
229+
let existing = vec![
230+
"/host/old1:/home/opencode/workspace".to_string(),
231+
"/host/cache:/home/opencode/.cache/opencode".to_string(),
232+
"/host/old2:/home/opencode/workspace:ro".to_string(),
233+
];
234+
let parsed = ParsedMount::parse("/host/new:/home/opencode/workspace").unwrap();
235+
236+
let (updated, outcome) =
237+
upsert_mount_by_target(&existing, "/host/new:/home/opencode/workspace", &parsed);
238+
239+
assert_eq!(
240+
updated,
241+
vec![
242+
"/host/cache:/home/opencode/.cache/opencode".to_string(),
243+
"/host/new:/home/opencode/workspace".to_string(),
244+
]
245+
);
246+
assert_eq!(
247+
outcome,
248+
MountUpsertOutcome::Replaced {
249+
replaced_mounts: vec![
250+
ParsedMount::parse("/host/old1:/home/opencode/workspace").unwrap(),
251+
ParsedMount::parse("/host/old2:/home/opencode/workspace:ro").unwrap(),
252+
],
253+
}
254+
);
255+
}
86256
}

0 commit comments

Comments
 (0)