Skip to content

Commit 37e9c65

Browse files
committed
added file backup before agentic file modify
1 parent 63fdf3c commit 37e9c65

5 files changed

Lines changed: 437 additions & 136 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.env
22
.local/
3-
build/*
3+
build/*
4+
.ti/

internal/filemanager/filemanager.go

Lines changed: 149 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
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
2224
func (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
4958
func (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
6776
func (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
87181
func (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)
110205
func (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
145239
func (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

Comments
 (0)