@@ -6,18 +6,22 @@ use std::process::Command;
66
77use 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 .
1414pub 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"
41147pub fn resolve_actor ( ) -> String {
42148 if let Ok ( actor) = env:: var ( "FLOW_ACTOR" ) {
0 commit comments