44 "fmt"
55 "os"
66 "path/filepath"
7+ "strings"
8+ "time"
79)
810
911// FileManager handles all file system operations
@@ -21,34 +23,41 @@ func NewFileManager(workspaceDir string) *FileManager {
2123// CreateFile creates a new file with optional initial content
2224func (fm * FileManager ) CreateFile (filePath string , content string ) error {
2325 fullPath := fm .resolvePath (filePath )
24-
26+
2527 // Create parent directories if they don't exist
2628 dir := filepath .Dir (fullPath )
2729 if err := os .MkdirAll (dir , 0755 ); err != nil {
2830 return fmt .Errorf ("failed to create directory %s: %w" , dir , err )
2931 }
30-
32+
33+ // Create backup if file exists (safeguard for overwrites)
34+ if _ , err := os .Stat (fullPath ); err == nil {
35+ if err := fm .createBackup (fullPath ); err != nil {
36+ return fmt .Errorf ("failed to create backup: %w" , err )
37+ }
38+ }
39+
3140 // Create the file
3241 file , err := os .Create (fullPath )
3342 if err != nil {
3443 return fmt .Errorf ("failed to create file %s: %w" , fullPath , err )
3544 }
3645 defer file .Close ()
37-
46+
3847 // Write initial content if provided
3948 if content != "" {
4049 if _ , err := file .WriteString (content ); err != nil {
4150 return fmt .Errorf ("failed to write content to file %s: %w" , fullPath , err )
4251 }
4352 }
44-
53+
4554 return nil
4655}
4756
4857// ReadFile reads file content from disk
4958func (fm * FileManager ) ReadFile (filePath string ) (string , error ) {
5059 fullPath := fm .resolvePath (filePath )
51-
60+
5261 content , err := os .ReadFile (fullPath )
5362 if err != nil {
5463 if os .IsNotExist (err ) {
@@ -59,34 +68,119 @@ func (fm *FileManager) ReadFile(filePath string) (string, error) {
5968 }
6069 return "" , fmt .Errorf ("failed to read file %s: %w" , fullPath , err )
6170 }
62-
71+
6372 return string (content ), nil
6473}
6574
6675// WriteFile writes content to file
6776func (fm * FileManager ) WriteFile (filePath string , content string ) error {
6877 fullPath := fm .resolvePath (filePath )
69-
78+
7079 // Create parent directories if they don't exist
7180 dir := filepath .Dir (fullPath )
7281 if err := os .MkdirAll (dir , 0755 ); err != nil {
7382 return fmt .Errorf ("failed to create directory %s: %w" , dir , err )
7483 }
75-
84+
85+ // Create backup if file exists
86+ if _ , err := os .Stat (fullPath ); err == nil {
87+ if err := fm .createBackup (fullPath ); err != nil {
88+ // Log error but proceed? Or fail?
89+ // User requirement implies safety is key. Let's fail if backup fails to ensure we don't lose data.
90+ return fmt .Errorf ("failed to create backup: %w" , err )
91+ }
92+ }
93+
7694 if err := os .WriteFile (fullPath , []byte (content ), 0644 ); err != nil {
7795 if os .IsPermission (err ) {
7896 return fmt .Errorf ("permission denied: %s" , fullPath )
7997 }
8098 return fmt .Errorf ("failed to write file %s: %w" , fullPath , err )
8199 }
82-
100+
101+ return nil
102+ }
103+
104+ // createBackup creates a copy of the file in the .ti directory
105+ func (fm * FileManager ) createBackup (fullPath string ) error {
106+ relPath , err := filepath .Rel (fm .workspaceDir , fullPath )
107+ if err != nil {
108+ return err
109+ }
110+ // Normalize path separators
111+ relPath = filepath .ToSlash (relPath )
112+
113+ // Don't backup files that are already in the .ti directory
114+ if strings .HasPrefix (relPath , ".ti/" ) {
115+ return nil
116+ }
117+
118+ tiDir := filepath .Join (fm .workspaceDir , ".ti" )
119+ // Check if .ti folder exists, if not create it
120+ if _ , err := os .Stat (tiDir ); os .IsNotExist (err ) {
121+ if err := os .MkdirAll (tiDir , 0755 ); err != nil {
122+ return fmt .Errorf ("failed to create .ti directory: %w" , err )
123+ }
124+ // Since we just created the directory, check/update .gitignore
125+ if err := fm .ensureGitIgnore (tiDir ); err != nil {
126+ // Log error but proceed
127+ fmt .Printf ("Warning: failed to update .gitignore: %v\n " , err )
128+ }
129+ }
130+
131+ content , err := os .ReadFile (fullPath )
132+ if err != nil {
133+ return fmt .Errorf ("failed to read file for backup: %w" , err )
134+ }
135+
136+ // Create timestamped filename: YYYYMMDD-HHMMSS_path_to_file
137+ // Replace slashes with underscores to flatten directory structure
138+ timestamp := time .Now ().Format ("20060102-150405" )
139+ safePath := strings .ReplaceAll (relPath , "/" , "_" )
140+ backupName := fmt .Sprintf ("%s_%s" , timestamp , safePath )
141+ backupPath := filepath .Join (tiDir , backupName )
142+
143+ if err := os .WriteFile (backupPath , content , 0644 ); err != nil {
144+ return fmt .Errorf ("failed to write backup file: %w" , err )
145+ }
146+
83147 return nil
84148}
85149
150+ // ListBackups returns a list of backup files for a given file path
151+ func (fm * FileManager ) ListBackups (filePath string ) ([]string , error ) {
152+ fullPath := fm .resolvePath (filePath )
153+ relPath , err := filepath .Rel (fm .workspaceDir , fullPath )
154+ if err != nil {
155+ return nil , err
156+ }
157+ relPath = filepath .ToSlash (relPath )
158+ safePath := strings .ReplaceAll (relPath , "/" , "_" )
159+
160+ tiDir := filepath .Join (fm .workspaceDir , ".ti" )
161+ entries , err := os .ReadDir (tiDir )
162+ if err != nil {
163+ if os .IsNotExist (err ) {
164+ return []string {}, nil
165+ }
166+ return nil , err
167+ }
168+
169+ var backups []string
170+ suffix := "_" + safePath
171+ for _ , entry := range entries {
172+ if ! entry .IsDir () && strings .HasSuffix (entry .Name (), suffix ) {
173+ backups = append (backups , entry .Name ())
174+ }
175+ }
176+
177+ return backups , nil
178+ }
179+
86180// DeleteFile deletes a file from disk
87181func (fm * FileManager ) DeleteFile (filePath string ) error {
88182 fullPath := fm .resolvePath (filePath )
89-
183+
90184 if err := os .Remove (fullPath ); err != nil {
91185 if os .IsNotExist (err ) {
92186 return fmt .Errorf ("file not found: %s" , fullPath )
@@ -96,7 +190,7 @@ func (fm *FileManager) DeleteFile(filePath string) error {
96190 }
97191 return fmt .Errorf ("failed to delete file %s: %w" , fullPath , err )
98192 }
99-
193+
100194 return nil
101195}
102196
@@ -106,6 +200,7 @@ func (fm *FileManager) FileExists(filePath string) bool {
106200 _ , err := os .Stat (fullPath )
107201 return err == nil
108202}
203+
109204// ListFiles returns a list of all files in the workspace directory (recursively)
110205func (fm * FileManager ) ListFiles () ([]string , error ) {
111206 var files []string
@@ -140,17 +235,57 @@ func (fm *FileManager) ListFiles() ([]string, error) {
140235 return files , nil
141236}
142237
143-
144238// resolvePath resolves a relative path to an absolute path within the workspace
145239func (fm * FileManager ) resolvePath (path string ) string {
146240 // Clean the path to prevent directory traversal
147241 cleanPath := filepath .Clean (path )
148-
242+
149243 // If path is already absolute, return it
150244 if filepath .IsAbs (cleanPath ) {
151245 return cleanPath
152246 }
153-
247+
154248 // Join with workspace directory
155249 return filepath .Join (fm .workspaceDir , cleanPath )
156250}
251+
252+ // ensureGitIgnore adds the .ti/ directory to .gitignore if it exists and is missing the entry
253+ func (fm * FileManager ) ensureGitIgnore (tiDir string ) error {
254+ gitIgnorePath := filepath .Join (fm .workspaceDir , ".gitignore" )
255+
256+ // Check if .gitignore exists
257+ if _ , err := os .Stat (gitIgnorePath ); os .IsNotExist (err ) {
258+ return nil // No .gitignore, nothing to do
259+ }
260+
261+ content , err := os .ReadFile (gitIgnorePath )
262+ if err != nil {
263+ return fmt .Errorf ("failed to read .gitignore: %w" , err )
264+ }
265+
266+ contentStr := string (content )
267+
268+ // Check if .ti/ or .ti is already ignored
269+ // We handle various line ending/spacing scenarios
270+ lines := strings .Split (contentStr , "\n " )
271+ for _ , line := range lines {
272+ trimmed := strings .TrimSpace (line )
273+ if trimmed == ".ti/" || trimmed == ".ti" {
274+ return nil // Already ignored
275+ }
276+ }
277+
278+ // Append .ti/ to .gitignore
279+ // Ensure we start on a new line if the file doesn't end with one
280+ newContent := contentStr
281+ if len (newContent ) > 0 && ! strings .HasSuffix (newContent , "\n " ) {
282+ newContent += "\n "
283+ }
284+ newContent += ".ti/\n "
285+
286+ if err := os .WriteFile (gitIgnorePath , []byte (newContent ), 0644 ); err != nil {
287+ return fmt .Errorf ("failed to update .gitignore: %w" , err )
288+ }
289+
290+ return nil
291+ }
0 commit comments