Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit a38faed

Browse files
z23ccclaude
andcommitted
feat: .flow/ symlink to shared .git/flow-state/flow/
flowctl init now creates the real state directory at .git/flow-state/flow/ and a .flow/ symlink pointing to it. This gives users a visible .flow/ in their project root while all worktrees share one state directory. - helpers.rs: get_flow_dir() returns $CWD/.flow/ (symlink-transparent) - helpers.rs: ensure_flow_symlink() creates real dir + symlink, migrates existing .flow/ contents if needed - init.rs: calls ensure_flow_symlink() before creating subdirs - worktree.sh: creates .flow/ symlink in new worktrees automatically Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5656518 commit a38faed

4 files changed

Lines changed: 141 additions & 12 deletions

File tree

flowctl/crates/flowctl-cli/src/commands/admin/init.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,23 @@ use super::{deep_merge, get_default_config, get_flow_dir, write_json_file};
1616
// ── Init command ────────────────────────────────────────────────────
1717

1818
pub fn cmd_init(json: bool) {
19-
let flow_dir = get_flow_dir();
19+
let cwd = std::env::current_dir()
20+
.unwrap_or_else(|e| error_exit(&format!("Cannot get current dir: {e}")));
2021
let mut actions: Vec<String> = Vec::new();
2122

23+
// Ensure .flow/ symlink → .git/flow-state/flow/ (or plain dir outside git)
24+
match crate::commands::helpers::ensure_flow_symlink(&cwd) {
25+
Ok(shared_dir) => {
26+
let flow_dir = get_flow_dir();
27+
if flow_dir.is_symlink() {
28+
actions.push(format!(".flow/ → {}", shared_dir.display()));
29+
}
30+
}
31+
Err(e) => error_exit(&format!("Failed to setup .flow/: {e}")),
32+
}
33+
34+
let flow_dir = get_flow_dir();
35+
2236
// Create directories if missing (idempotent, never destroys existing)
2337
for subdir in &[EPICS_DIR, SPECS_DIR, TASKS_DIR, MEMORY_DIR, REVIEWS_DIR] {
2438
let dir_path = flow_dir.join(subdir);
@@ -61,8 +75,6 @@ pub fn cmd_init(json: bool) {
6175
}
6276

6377
// Create/open flow.db (runs migrations automatically)
64-
let cwd = std::env::current_dir()
65-
.unwrap_or_else(|e| error_exit(&format!("Cannot get current dir: {e}")));
6678
match crate::commands::db_shim::open(&cwd) {
6779
Ok(conn) => {
6880
actions.push("flow.db ready".to_string());

flowctl/crates/flowctl-cli/src/commands/helpers.rs

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@ use std::process::Command;
66

77
use flowctl_core::types::FLOW_DIR;
88

9-
/// Resolve the shared flow directory.
9+
/// Get the .flow/ directory path.
1010
///
11-
/// In a git repo, uses `git rev-parse --git-common-dir` so all worktrees
12-
/// share one `.flow/` state (at `.git/flow-state/flow/`).
13-
/// Falls back to `$CWD/.flow/` outside a git repo.
11+
/// Returns `$CWD/.flow/` which is expected to be a symlink to the shared
12+
/// state dir (`.git/flow-state/flow/`) in git repos. The symlink is created
13+
/// by `flowctl init` and by the worktree kit on worktree creation.
1414
pub fn get_flow_dir() -> PathBuf {
15-
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
16-
resolve_flow_dir(&cwd)
15+
env::current_dir()
16+
.unwrap_or_else(|_| PathBuf::from("."))
17+
.join(FLOW_DIR)
1718
}
1819

19-
/// Inner resolver, testable with explicit working dir.
20-
pub fn resolve_flow_dir(working_dir: &Path) -> PathBuf {
20+
/// Resolve the shared flow state directory (real path, not symlink).
21+
///
22+
/// In a git repo: `.git/flow-state/flow/` (shared across worktrees).
23+
/// Outside git: `$CWD/.flow/` (regular directory).
24+
pub fn resolve_shared_flow_dir(working_dir: &Path) -> PathBuf {
2125
let git_result = Command::new("git")
2226
.args(["rev-parse", "--git-common-dir"])
2327
.current_dir(working_dir)
@@ -37,6 +41,108 @@ pub fn resolve_flow_dir(working_dir: &Path) -> PathBuf {
3741
}
3842
}
3943

44+
/// Create `.flow/` symlink pointing to the shared state directory.
45+
///
46+
/// In a git repo, creates `.git/flow-state/flow/` (real dir) and
47+
/// `$CWD/.flow/` → `.git/flow-state/flow/` (symlink).
48+
/// Outside git, creates `$CWD/.flow/` as a regular directory.
49+
/// Idempotent: no-op if already correctly linked or is a regular dir.
50+
pub fn ensure_flow_symlink(working_dir: &Path) -> Result<PathBuf, String> {
51+
let shared_dir = resolve_shared_flow_dir(working_dir);
52+
let local_link = working_dir.join(FLOW_DIR);
53+
54+
// If shared == local (non-git fallback), just create the dir
55+
if shared_dir == local_link {
56+
std::fs::create_dir_all(&shared_dir)
57+
.map_err(|e| format!("failed to create {}: {e}", shared_dir.display()))?;
58+
return Ok(shared_dir);
59+
}
60+
61+
// Create the real shared directory
62+
std::fs::create_dir_all(&shared_dir)
63+
.map_err(|e| format!("failed to create {}: {e}", shared_dir.display()))?;
64+
65+
// Handle existing .flow/
66+
if local_link.exists() || local_link.symlink_metadata().is_ok() {
67+
if local_link.is_symlink() {
68+
// Already a symlink — check if it points to the right place
69+
if let Ok(target) = std::fs::read_link(&local_link) {
70+
let target_canonical = std::fs::canonicalize(&target)
71+
.or_else(|_| std::fs::canonicalize(working_dir.join(&target)))
72+
.unwrap_or(target);
73+
let shared_canonical = std::fs::canonicalize(&shared_dir)
74+
.unwrap_or_else(|_| shared_dir.clone());
75+
if target_canonical == shared_canonical {
76+
return Ok(shared_dir); // Already correct
77+
}
78+
}
79+
// Wrong target — remove and re-create
80+
std::fs::remove_file(&local_link)
81+
.map_err(|e| format!("failed to remove stale symlink: {e}"))?;
82+
} else if local_link.is_dir() {
83+
// Existing real .flow/ dir — migrate contents to shared, then replace with symlink
84+
migrate_dir_contents(&local_link, &shared_dir)?;
85+
std::fs::remove_dir_all(&local_link)
86+
.map_err(|e| format!("failed to remove old .flow/: {e}"))?;
87+
} else {
88+
return Err(format!(".flow exists but is not a dir or symlink: {}", local_link.display()));
89+
}
90+
}
91+
92+
// Create symlink
93+
#[cfg(unix)]
94+
std::os::unix::fs::symlink(&shared_dir, &local_link)
95+
.map_err(|e| format!("failed to create symlink: {e}"))?;
96+
97+
#[cfg(not(unix))]
98+
{
99+
// Windows fallback: just use the shared dir directly, no symlink
100+
std::fs::create_dir_all(&local_link)
101+
.map_err(|e| format!("failed to create {}: {e}", local_link.display()))?;
102+
}
103+
104+
Ok(shared_dir)
105+
}
106+
107+
/// Move contents from src dir to dst dir (non-recursive, files + dirs).
108+
fn migrate_dir_contents(src: &Path, dst: &Path) -> Result<(), String> {
109+
let entries = std::fs::read_dir(src)
110+
.map_err(|e| format!("failed to read {}: {e}", src.display()))?;
111+
112+
for entry in entries {
113+
let entry = entry.map_err(|e| format!("read_dir entry: {e}"))?;
114+
let dest = dst.join(entry.file_name());
115+
if !dest.exists() {
116+
std::fs::rename(entry.path(), &dest)
117+
.or_else(|_| {
118+
// rename may fail across filesystems; fall back to copy
119+
if entry.path().is_dir() {
120+
copy_dir_recursive(&entry.path(), &dest)
121+
} else {
122+
std::fs::copy(&entry.path(), &dest).map(|_| ())
123+
}
124+
})
125+
.map_err(|e| format!("migrate {}: {e}", entry.file_name().to_string_lossy()))?;
126+
}
127+
}
128+
Ok(())
129+
}
130+
131+
/// Recursively copy a directory.
132+
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), std::io::Error> {
133+
std::fs::create_dir_all(dst)?;
134+
for entry in std::fs::read_dir(src)? {
135+
let entry = entry?;
136+
let dest = dst.join(entry.file_name());
137+
if entry.file_type()?.is_dir() {
138+
copy_dir_recursive(&entry.path(), &dest)?;
139+
} else {
140+
std::fs::copy(entry.path(), &dest)?;
141+
}
142+
}
143+
Ok(())
144+
}
145+
40146
/// Resolve current actor: FLOW_ACTOR env > git config user.email > git config user.name > $USER > "unknown"
41147
pub fn resolve_actor() -> String {
42148
if let Ok(actor) = env::var("FLOW_ACTOR") {

flowctl/crates/flowctl-cli/src/commands/mcp.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ fn handle_tools_call(id: &Value, request: &Value) -> Value {
184184
/// Resolve flow_dir and open DB connection for direct service calls.
185185
fn mcp_context() -> Result<(PathBuf, Option<libsql::Connection>), String> {
186186
let cwd = env::current_dir().map_err(|e| format!("cannot get cwd: {e}"))?;
187-
let flow_dir = super::helpers::resolve_flow_dir(&cwd);
187+
let flow_dir = super::helpers::get_flow_dir();
188188
let rt = tokio::runtime::Builder::new_current_thread()
189189
.enable_all()
190190
.build()

skills/flow-code-worktree-kit/scripts/worktree.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,17 @@ case "$cmd" in
143143
fi
144144

145145
copy_env "$target"
146+
147+
# Create .flow/ symlink in worktree → shared .git/flow-state/flow/
148+
git_common_dir="$(git -C "$target" rev-parse --git-common-dir 2>/dev/null || true)"
149+
if [[ -n "$git_common_dir" ]]; then
150+
shared_flow="$git_common_dir/flow-state/flow"
151+
wt_flow="$target/.flow"
152+
if [[ -d "$shared_flow" && ! -e "$wt_flow" ]]; then
153+
ln -s "$shared_flow" "$wt_flow"
154+
fi
155+
fi
156+
146157
echo "created: $target"
147158
;;
148159
list)

0 commit comments

Comments
 (0)