Skip to content

Commit e6394f7

Browse files
committed
feat: range-based port allocation + framework-aware env vars (v0.7.1)
- Port ranges: each worktree gets a 10-port block (3000-3009, 3010-3019, etc.) configurable via [isolation] port_range_size in .workz.toml - Framework detection: auto-detects 14 web frameworks from project files (Next.js, Vite, Express, NestJs, Django, Flask, FastAPI, Spring Boot, etc.) - Framework-specific env vars: SERVER_PORT (Spring), FLASK_RUN_PORT (Flask), UVICORN_PORT (FastAPI), VITE_PORT (Vite) written alongside PORT - MCP server: workz_start tool now accepts isolated parameter - Backward compatible with existing ports.json (port_count defaults to 1)
1 parent 1dcef65 commit e6394f7

8 files changed

Lines changed: 352 additions & 48 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "workz"
3-
version = "0.7.0"
3+
version = "0.7.1"
44
edition = "2021"
55
description = "Zoxide for Git worktrees — zero-config sync, fuzzy switching, AI-ready"
66
license = "MIT OR Apache-2.0"

src/config.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ pub struct Config {
1010
pub sync: SyncConfig,
1111
#[serde(default)]
1212
pub hooks: HooksConfig,
13+
#[serde(default)]
14+
pub isolation: IsolationConfig,
1315
}
1416

1517
#[derive(Debug, Deserialize)]
@@ -38,6 +40,29 @@ pub struct HooksConfig {
3840
pub pre_done: Option<String>,
3941
}
4042

43+
#[derive(Debug, Deserialize)]
44+
pub struct IsolationConfig {
45+
/// Number of ports to allocate per worktree (default: 10)
46+
#[serde(default = "default_port_range_size")]
47+
pub port_range_size: u16,
48+
49+
/// Base port for first worktree (default: 3000)
50+
#[serde(default = "default_base_port")]
51+
pub base_port: u16,
52+
}
53+
54+
fn default_port_range_size() -> u16 { 10 }
55+
fn default_base_port() -> u16 { 3000 }
56+
57+
impl Default for IsolationConfig {
58+
fn default() -> Self {
59+
Self {
60+
port_range_size: default_port_range_size(),
61+
base_port: default_base_port(),
62+
}
63+
}
64+
}
65+
4166
fn default_symlink_dirs() -> Vec<String> {
4267
[
4368
// JavaScript / Node
@@ -171,5 +196,14 @@ fn merge_configs(global: Config, project: Config) -> Config {
171196
pre_done: project.hooks.pre_done.or(global.hooks.pre_done),
172197
};
173198

174-
Config { sync, hooks }
199+
let default_iso = IsolationConfig::default();
200+
let isolation = if project.isolation.port_range_size != default_iso.port_range_size
201+
|| project.isolation.base_port != default_iso.base_port
202+
{
203+
project.isolation
204+
} else {
205+
global.isolation
206+
};
207+
208+
Config { sync, hooks, isolation }
175209
}

src/fleet.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ pub fn cmd_start(
140140
println!("\nsyncing dependencies...");
141141
for (i, (ft, wt_path)) in created.iter().enumerate() {
142142
print!(" [{}/{}] {}... ", i + 1, n, ft.branch);
143-
sync::sync_worktree(&root, wt_path, &config.sync)?;
143+
let _framework = sync::sync_worktree(&root, wt_path, &config.sync)?;
144144
println!("done");
145145
}
146146

src/isolation.rs

Lines changed: 144 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use std::collections::HashMap;
44
use std::path::{Path, PathBuf};
55
use std::process::Command;
66

7+
use crate::sync::Framework;
8+
79
// ── Registry types ───────────────────────────────────────────────────────────
810

911
#[derive(Serialize, Deserialize, Debug, Default)]
@@ -21,15 +23,24 @@ fn default_base_port() -> u16 {
2123
#[derive(Serialize, Deserialize, Debug, Clone)]
2224
pub struct PortAllocation {
2325
pub port: u16,
26+
/// Number of ports in the allocated range (backward compat: defaults to 1).
27+
#[serde(default = "default_port_count")]
28+
pub port_count: u16,
2429
pub branch: String,
2530
pub db_name: String,
2631
pub compose_project: String,
2732
pub worktree_path: String,
2833
pub allocated_at: String,
2934
}
3035

36+
fn default_port_count() -> u16 {
37+
1
38+
}
39+
3140
pub struct IsolationConfig {
3241
pub port: u16,
42+
pub port_end: u16,
43+
pub port_count: u16,
3344
pub db_name: String,
3445
pub compose_project: String,
3546
}
@@ -79,30 +90,57 @@ pub fn branch_to_slug(branch: &str) -> String {
7990

8091
// ── Port allocation ──────────────────────────────────────────────────────────
8192

82-
fn next_available_port(registry: &PortRegistry) -> u16 {
83-
let used: std::collections::HashSet<u16> =
84-
registry.allocations.values().map(|a| a.port).collect();
93+
/// Allocate a contiguous block of `range_size` ports that doesn't overlap any
94+
/// existing allocation. Aligns to `range_size` boundaries for clean ranges.
95+
fn next_available_port_range(registry: &PortRegistry, range_size: u16) -> u16 {
96+
let occupied: Vec<(u16, u16)> = registry
97+
.allocations
98+
.values()
99+
.map(|a| (a.port, a.port + a.port_count))
100+
.collect();
101+
85102
let base = if registry.base_port == 0 { 3000 } else { registry.base_port };
86-
let mut port = base;
87-
while used.contains(&port) {
88-
port += 1;
103+
let mut candidate = base;
104+
105+
// Align to range_size boundaries
106+
if range_size > 1 && candidate % range_size != 0 {
107+
candidate = candidate + range_size - (candidate % range_size);
108+
}
109+
110+
loop {
111+
let candidate_end = candidate + range_size;
112+
let overlaps = occupied
113+
.iter()
114+
.any(|&(start, end)| candidate < end && candidate_end > start);
115+
if !overlaps {
116+
return candidate;
117+
}
118+
candidate += range_size;
119+
if candidate > 60000 {
120+
return candidate;
121+
}
89122
}
90-
port
91123
}
92124

93125
// ── Main API ─────────────────────────────────────────────────────────────────
94126

95-
/// Allocate a port, compute derived names, update registry, write .env.local.
96-
pub fn setup_isolation(branch: &str, wt_path: &Path) -> Result<IsolationConfig> {
127+
/// Allocate a port range, compute derived names, update registry, write .env.local.
128+
pub fn setup_isolation(
129+
branch: &str,
130+
wt_path: &Path,
131+
range_size: u16,
132+
framework: Framework,
133+
) -> Result<IsolationConfig> {
97134
let mut registry = load_registry();
98135
let slug = branch_to_slug(branch);
99136

100137
let alloc = if let Some(existing) = registry.allocations.get(&slug) {
101138
existing.clone()
102139
} else {
103-
let port = next_available_port(&registry);
140+
let port = next_available_port_range(&registry, range_size);
104141
let alloc = PortAllocation {
105142
port,
143+
port_count: range_size,
106144
branch: branch.to_string(),
107145
db_name: slug.clone(),
108146
compose_project: slug.clone(),
@@ -114,10 +152,12 @@ pub fn setup_isolation(branch: &str, wt_path: &Path) -> Result<IsolationConfig>
114152
alloc
115153
};
116154

117-
write_env_local(wt_path, &alloc)?;
155+
write_env_local(wt_path, &alloc, framework)?;
118156

119157
Ok(IsolationConfig {
120158
port: alloc.port,
159+
port_end: alloc.port + alloc.port_count - 1,
160+
port_count: alloc.port_count,
121161
db_name: alloc.db_name.clone(),
122162
compose_project: alloc.compose_project.clone(),
123163
})
@@ -157,19 +197,46 @@ pub fn get_allocation(branch: &str) -> Option<PortAllocation> {
157197

158198
// ── .env.local writer ────────────────────────────────────────────────────────
159199

160-
fn write_env_local(wt_path: &Path, alloc: &PortAllocation) -> Result<()> {
161-
let content = format!(
162-
"# Generated by workz --isolated — do not edit manually\n\
163-
PORT={port}\n\
164-
DB_NAME={db}\n\
165-
DATABASE_URL=postgres://localhost/{db}\n\
166-
COMPOSE_PROJECT_NAME={compose}\n\
167-
REDIS_URL=redis://localhost:{redis}\n",
168-
port = alloc.port,
169-
db = alloc.db_name,
170-
compose = alloc.compose_project,
171-
redis = alloc.port + 1000,
172-
);
200+
fn write_env_local(wt_path: &Path, alloc: &PortAllocation, framework: Framework) -> Result<()> {
201+
let port = alloc.port;
202+
let port_end = alloc.port + alloc.port_count - 1;
203+
204+
let mut lines = vec![
205+
"# Generated by workz --isolated — do not edit manually".to_string(),
206+
format!("PORT={}", port),
207+
];
208+
209+
// Only write PORT_END when we have a range (not a single port)
210+
if alloc.port_count > 1 {
211+
lines.push(format!("PORT_END={}", port_end));
212+
}
213+
214+
// Framework-specific port vars
215+
match framework {
216+
Framework::SpringBoot => {
217+
lines.push(format!("SERVER_PORT={}", port));
218+
}
219+
Framework::Flask => {
220+
lines.push(format!("FLASK_RUN_PORT={}", port));
221+
}
222+
Framework::FastApi => {
223+
lines.push(format!("UVICORN_PORT={}", port));
224+
}
225+
Framework::Vite => {
226+
lines.push(format!("VITE_PORT={}", port));
227+
}
228+
_ => {}
229+
}
230+
231+
lines.push(format!("DB_NAME={}", alloc.db_name));
232+
lines.push(format!("DATABASE_URL=postgres://localhost/{}", alloc.db_name));
233+
lines.push(format!("COMPOSE_PROJECT_NAME={}", alloc.compose_project));
234+
235+
// Redis on port+1 (within the allocated range, not port+1000)
236+
let redis_port = if alloc.port_count > 1 { port + 1 } else { port + 1000 };
237+
lines.push(format!("REDIS_URL=redis://localhost:{}", redis_port));
238+
239+
let content = lines.join("\n") + "\n";
173240
std::fs::write(wt_path.join(".env.local"), content)?;
174241
Ok(())
175242
}
@@ -228,4 +295,57 @@ mod tests {
228295
// 2024-03-04T12:00:00Z = 1709553600
229296
assert_eq!(unix_secs_to_rfc3339(1709553600), "2024-03-04T12:00:00Z");
230297
}
298+
299+
#[test]
300+
fn range_allocation_no_overlap() {
301+
let mut registry = PortRegistry { base_port: 3000, allocations: HashMap::new() };
302+
303+
let port1 = next_available_port_range(&registry, 10);
304+
assert_eq!(port1, 3000);
305+
306+
registry.allocations.insert("first".into(), PortAllocation {
307+
port: 3000, port_count: 10,
308+
branch: "a".into(), db_name: "a".into(),
309+
compose_project: "a".into(), worktree_path: "/tmp/a".into(),
310+
allocated_at: "2024-01-01T00:00:00Z".into(),
311+
});
312+
313+
let port2 = next_available_port_range(&registry, 10);
314+
assert_eq!(port2, 3010);
315+
}
316+
317+
#[test]
318+
fn range_allocation_backward_compat() {
319+
let mut registry = PortRegistry { base_port: 3000, allocations: HashMap::new() };
320+
registry.allocations.insert("old".into(), PortAllocation {
321+
port: 3000, port_count: 1,
322+
branch: "old".into(), db_name: "old".into(),
323+
compose_project: "old".into(), worktree_path: "/tmp/old".into(),
324+
allocated_at: "2024-01-01T00:00:00Z".into(),
325+
});
326+
327+
let port = next_available_port_range(&registry, 10);
328+
assert_eq!(port, 3010);
329+
}
330+
331+
#[test]
332+
fn range_allocation_gap_filling() {
333+
let mut registry = PortRegistry { base_port: 3000, allocations: HashMap::new() };
334+
335+
registry.allocations.insert("first".into(), PortAllocation {
336+
port: 3000, port_count: 10,
337+
branch: "a".into(), db_name: "a".into(),
338+
compose_project: "a".into(), worktree_path: "/tmp/a".into(),
339+
allocated_at: "2024-01-01T00:00:00Z".into(),
340+
});
341+
registry.allocations.insert("third".into(), PortAllocation {
342+
port: 3020, port_count: 10,
343+
branch: "c".into(), db_name: "c".into(),
344+
compose_project: "c".into(), worktree_path: "/tmp/c".into(),
345+
allocated_at: "2024-01-01T00:00:00Z".into(),
346+
});
347+
348+
let port = next_available_port_range(&registry, 10);
349+
assert_eq!(port, 3010);
350+
}
231351
}

src/main.rs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ fn cmd_start(
8787
git::worktree_add(&wt_path, branch, base)?;
8888
println!(" worktree created at {}", wt_path.display());
8989

90-
if !no_sync {
91-
let config = config::load_config(&root)?;
92-
sync::sync_worktree(&root, &wt_path, &config.sync)?;
90+
let config = config::load_config(&root)?;
91+
92+
let framework = if !no_sync {
93+
let fw = sync::sync_worktree(&root, &wt_path, &config.sync)?;
9394

9495
// Run post_start hook if configured
9596
if let Some(hook) = &config.hooks.post_start {
@@ -102,15 +103,29 @@ fn cmd_start(
102103
eprintln!(" warning: post_start hook exited with {}", status);
103104
}
104105
}
105-
}
106+
fw
107+
} else {
108+
sync::Framework::Unknown
109+
};
106110

107111
if isolated {
108-
let iso = isolation::setup_isolation(branch, &wt_path)?;
112+
let iso = isolation::setup_isolation(
113+
branch,
114+
&wt_path,
115+
config.isolation.port_range_size,
116+
framework,
117+
)?;
109118
println!(" isolated environment:");
110-
println!(" PORT={} → .env.local", iso.port);
119+
if iso.port_count > 1 {
120+
println!(" PORT={}..{} → .env.local", iso.port, iso.port_end);
121+
} else {
122+
println!(" PORT={} → .env.local", iso.port);
123+
}
111124
println!(" DB_NAME={}", iso.db_name);
112125
println!(" COMPOSE_PROJECT_NAME={}", iso.compose_project);
113-
println!(" REDIS_URL=redis://localhost:{}", iso.port + 1000);
126+
if framework != sync::Framework::Unknown {
127+
println!(" framework={:?}", framework);
128+
}
114129
}
115130

116131
if docker {
@@ -428,7 +443,7 @@ fn cmd_sync() -> Result<()> {
428443

429444
let config = config::load_config(&root)?;
430445
println!("syncing worktree at {}", cwd.display());
431-
sync::sync_worktree(&root, &cwd, &config.sync)?;
446+
let _framework = sync::sync_worktree(&root, &cwd, &config.sync)?;
432447
println!("done!");
433448
Ok(())
434449
}
@@ -464,7 +479,13 @@ fn cmd_status() -> Result<()> {
464479
let docker = if has_compose { " [docker]" } else { "" };
465480

466481
let port_info = isolation::get_allocation(&wt.branch)
467-
.map(|a| format!(" PORT:{}", a.port))
482+
.map(|a| {
483+
if a.port_count > 1 {
484+
format!(" PORT:{}-{}", a.port, a.port + a.port_count - 1)
485+
} else {
486+
format!(" PORT:{}", a.port)
487+
}
488+
})
468489
.unwrap_or_default();
469490

470491
println!(

0 commit comments

Comments
 (0)