Skip to content

Commit 21ea53f

Browse files
fix: file counting, same-path safety, overwrite prompt, and release 0.1.1
1 parent 2ad8c6f commit 21ea53f

12 files changed

Lines changed: 320 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
all notable changes to this project will be documented in this file.
44

5+
## [0.1.1] - 2026-08-15
6+
7+
### fixes
8+
- fixed delete operations reporting 0 files in completion summary
9+
- fixed same-path operations (copy/move to self) potentially destroying files
10+
- overwrite prompt now defaults to yes on enter (`[Y/n]`)
11+
- improved error and cancellation handling in progress ui
12+
513
## [0.1.0] - 2026-08-14
614

715
### features
@@ -12,4 +20,5 @@ all notable changes to this project will be documented in this file.
1220
- safe overwrite prompts and directory delete validation
1321
- non-tty fallback for script pipelines
1422

23+
[0.1.1]: https://github.com/programmersd21/zap/releases/tag/v0.1.1
1524
[0.1.0]: https://github.com/programmersd21/zap/releases/tag/v0.1.0

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
### zap
1+
# zap
22

33
fast file operations with real-time progress.
44

@@ -11,8 +11,8 @@ fast file operations with real-time progress.
1111
- **fast** — 1 mb buffered streaming io for large file copies
1212
- **real-time progress** — transfer speeds, file counts, and estimated time remaining
1313
- **terminal aware** — dynamic width adjustments and automatic path truncation
14-
- **safe defaults**confirmation on overwrites and protection for recursive deletes
15-
- **script friendly** — automatically falls back to quiet/direct output when stdout is not a tty
14+
- **safe defaults**same-path identity protection, overwrite confirmations (`[Y/n]`), and recursive delete checks
15+
- **script friendly** — automatically falls back to direct output when stdout is not a tty
1616

1717
## install
1818

assets/demo.gif

56.5 KB
Loading

cmd/zap/main.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
"github.com/programmersd21/zap/internal/walk"
2121
)
2222

23-
const version = "0.1.0"
23+
const version = "0.1.1"
2424

2525
var (
2626
flagMove bool
@@ -155,6 +155,9 @@ func runCopyWithProgress(ctx context.Context, sources []string, dest string, sta
155155
defer close(done)
156156
var cumulBytes, cumulFiles int64
157157
for _, src := range sources {
158+
if ctx.Err() != nil {
159+
return
160+
}
158161
dst := destPath(dest, src)
159162
opts := ops.CopyOptions{
160163
Force: true,
@@ -167,7 +170,13 @@ func runCopyWithProgress(ctx context.Context, sources []string, dest string, sta
167170
ec.Add(src, err)
168171
}
169172
}
170-
p.Send(ui.CompletedMsg{})
173+
if ctx.Err() == nil {
174+
if ec.HasErrors() {
175+
p.Send(ui.ErrorMsg{Err: fmt.Errorf("%d error(s)", ec.Count())})
176+
} else {
177+
p.Send(ui.CompletedMsg{})
178+
}
179+
}
171180
}()
172181

173182
if _, err := p.Run(); err != nil {
@@ -241,23 +250,38 @@ func runDeleteWithProgress(ctx context.Context, paths []string, stats walk.Stats
241250
model := ui.NewModel(ui.ThemeMocha, ui.OpDelete, flagVerbose, 0, stats.TotalFiles)
242251
p := tea.NewProgram(model)
243252

253+
done := make(chan struct{})
244254
go func() {
255+
defer close(done)
256+
var cumulFiles int64
245257
for _, path := range paths {
258+
if ctx.Err() != nil {
259+
return
260+
}
246261
opts := ops.DeleteOptions{
247-
Recursive: flagRecursive,
248-
Program: p,
249-
Errors: ec,
262+
Recursive: flagRecursive,
263+
Program: p,
264+
Errors: ec,
265+
CumulFiles: &cumulFiles,
250266
}
251267
if err := ops.Delete(path, opts); err != nil && !flagForce {
252268
ec.Add(path, err)
253269
}
254270
}
255-
p.Send(ui.CompletedMsg{})
271+
if ctx.Err() == nil {
272+
if ec.HasErrors() {
273+
p.Send(ui.ErrorMsg{Err: fmt.Errorf("%d error(s)", ec.Count())})
274+
} else {
275+
p.Send(ui.CompletedMsg{})
276+
}
277+
}
256278
}()
257279

258280
if _, err := p.Run(); err != nil {
281+
<-done
259282
return fmt.Errorf("ui error: %w", err)
260283
}
284+
<-done
261285

262286
if ctx.Err() != nil {
263287
printInterrupted("delete", stats.TotalFiles)
@@ -276,13 +300,14 @@ func destPath(dest, src string) string {
276300

277301
func promptYN(r *bufio.Reader, msg string) bool {
278302
styles := ui.NewStyles(ui.ThemeMocha)
279-
fmt.Fprintf(os.Stderr, "%s %s ",
303+
fmt.Fprintf(os.Stderr, "%s %s %s ",
280304
styles.Warning.Bold(true).Render("?"),
281305
styles.Primary.Render(msg),
306+
styles.Muted.Render("[Y/n]"),
282307
)
283308
resp, _ := r.ReadString('\n')
284309
resp = strings.ToLower(strings.TrimSpace(resp))
285-
return resp == "y" || resp == "yes"
310+
return resp == "" || resp == "y" || resp == "yes"
286311
}
287312

288313
func summarizeErrors(ec *errs.Collector) error {

cmd/zap/main_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ func TestVersion(t *testing.T) {
2828
t.Fatalf("failed to run --version: %v", err)
2929
}
3030

31-
if !strings.Contains(string(output), "0.1.0") {
32-
t.Errorf("expected version 0.1.0, got: %s", output)
31+
if !strings.Contains(string(output), "0.1.1") {
32+
t.Errorf("expected version 0.1.1, got: %s", output)
3333
}
3434
}
3535

internal/ops/copy.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,34 @@ type CopyOptions struct {
2020
CumulFiles *int64
2121
}
2222

23+
func samePath(a, b string) bool {
24+
absA, errA := filepath.Abs(a)
25+
absB, errB := filepath.Abs(b)
26+
if errA != nil || errB != nil {
27+
return a == b
28+
}
29+
if filepath.Clean(absA) == filepath.Clean(absB) {
30+
return true
31+
}
32+
// check filesystem identity via inode
33+
infoA, errA := os.Stat(absA)
34+
infoB, errB := os.Stat(absB)
35+
if errA != nil || errB != nil {
36+
return false
37+
}
38+
return os.SameFile(infoA, infoB)
39+
}
40+
2341
func Copy(src, dst string, opts CopyOptions) error {
2442
srcInfo, err := os.Lstat(src)
2543
if err != nil {
2644
return fmt.Errorf("stat %s: %w", src, err)
2745
}
2846

47+
if samePath(src, dst) {
48+
return fmt.Errorf("%s and %s are the same file", src, dst)
49+
}
50+
2951
if dstInfo, err := os.Lstat(dst); err == nil {
3052
if !opts.Force {
3153
return fmt.Errorf("destination %s exists", dst)

internal/ops/count_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package ops
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/programmersd21/zap/internal/errs"
9+
"github.com/programmersd21/zap/internal/walk"
10+
)
11+
12+
func TestCopyFileCount(t *testing.T) {
13+
srcDir := t.TempDir()
14+
dstDir := filepath.Join(t.TempDir(), "dst")
15+
16+
// create a tree resembling the reported structure:
17+
// src/file1.txt, src/sub/file2.txt, src/sub/deep/file3.txt
18+
paths := []string{
19+
filepath.Join(srcDir, "file1.txt"),
20+
filepath.Join(srcDir, "sub", "file2.txt"),
21+
filepath.Join(srcDir, "sub", "deep", "file3.txt"),
22+
}
23+
for _, p := range paths {
24+
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
25+
t.Fatal(err)
26+
}
27+
if err := os.WriteFile(p, []byte("data"), 0644); err != nil {
28+
t.Fatal(err)
29+
}
30+
}
31+
32+
// verify pre-scan count matches
33+
stats, err := walk.ComputeStats([]string{srcDir})
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
if stats.TotalFiles != 3 {
38+
t.Errorf("ComputeStats: expected 3 files, got %d", stats.TotalFiles)
39+
}
40+
41+
// verify copy operation count matches
42+
var cumulFiles int64
43+
var cumulBytes int64
44+
ec := errs.NewCollector()
45+
opts := CopyOptions{
46+
Force: false,
47+
Errors: ec,
48+
CumulBytes: &cumulBytes,
49+
CumulFiles: &cumulFiles,
50+
}
51+
if err := Copy(srcDir, dstDir, opts); err != nil {
52+
t.Fatal(err)
53+
}
54+
if cumulFiles != 3 {
55+
t.Errorf("CumulFiles after copy: expected 3, got %d", cumulFiles)
56+
}
57+
if cumulFiles != stats.TotalFiles {
58+
t.Errorf("count mismatch: pre-scan=%d, actual=%d", stats.TotalFiles, cumulFiles)
59+
}
60+
}
61+
62+
func TestCopyFileCountLargerTree(t *testing.T) {
63+
srcDir := t.TempDir()
64+
dstDir := filepath.Join(t.TempDir(), "dst")
65+
66+
// create exactly 10 files in a nested structure
67+
files := []string{
68+
"build/please",
69+
"justfile",
70+
"src/cli.odin",
71+
"src/main.odin",
72+
"src/pam/context.odin",
73+
"src/pam/ffi.odin",
74+
"src/pam/pam.odin",
75+
"src/ticket.odin",
76+
"src/timestamp.odin",
77+
"src/utils.odin",
78+
}
79+
for _, f := range files {
80+
p := filepath.Join(srcDir, f)
81+
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
82+
t.Fatal(err)
83+
}
84+
if err := os.WriteFile(p, []byte("content"), 0644); err != nil {
85+
t.Fatal(err)
86+
}
87+
}
88+
89+
stats, err := walk.ComputeStats([]string{srcDir})
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
if stats.TotalFiles != 10 {
94+
t.Errorf("ComputeStats: expected 10 files, got %d", stats.TotalFiles)
95+
}
96+
97+
var cumulFiles int64
98+
var cumulBytes int64
99+
ec := errs.NewCollector()
100+
opts := CopyOptions{
101+
Force: false,
102+
Errors: ec,
103+
CumulBytes: &cumulBytes,
104+
CumulFiles: &cumulFiles,
105+
}
106+
if err := Copy(srcDir, dstDir, opts); err != nil {
107+
t.Fatal(err)
108+
}
109+
if cumulFiles != 10 {
110+
t.Errorf("CumulFiles: expected 10, got %d", cumulFiles)
111+
}
112+
}
113+
114+
func TestDeleteFileCount(t *testing.T) {
115+
dir := t.TempDir()
116+
117+
// create dir with 1 file (the exact reported bug scenario)
118+
subdir := filepath.Join(dir, "something")
119+
if err := os.MkdirAll(subdir, 0755); err != nil {
120+
t.Fatal(err)
121+
}
122+
if err := os.WriteFile(filepath.Join(subdir, "meow"), []byte("x"), 0644); err != nil {
123+
t.Fatal(err)
124+
}
125+
126+
stats, err := walk.ComputeStats([]string{subdir})
127+
if err != nil {
128+
t.Fatal(err)
129+
}
130+
if stats.TotalFiles != 1 {
131+
t.Errorf("ComputeStats: expected 1 file, got %d", stats.TotalFiles)
132+
}
133+
134+
// verify delete counts the file
135+
var cumulFiles int64
136+
ec := errs.NewCollector()
137+
opts := DeleteOptions{
138+
Recursive: true,
139+
Errors: ec,
140+
CumulFiles: &cumulFiles,
141+
}
142+
if err := Delete(subdir, opts); err != nil {
143+
t.Fatal(err)
144+
}
145+
// should count the file + subdir + root dir = 3 entries
146+
// but the summary shows filesDone which is cumulFiles
147+
// sendDeleteProgress increments for files, dirs, and root
148+
if cumulFiles < 1 {
149+
t.Errorf("CumulFiles after delete: expected >= 1, got %d", cumulFiles)
150+
}
151+
}

internal/ops/delete.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func deleteFile(path string, opts DeleteOptions) error {
4141
if err := os.Remove(path); err != nil {
4242
return err
4343
}
44-
sendDeleteProgress(opts, path)
44+
sendDeleteProgress(opts, path, true)
4545
return nil
4646
}
4747

@@ -74,7 +74,7 @@ func deleteDir(path string, opts DeleteOptions) error {
7474
opts.Errors.Add(f, err)
7575
}
7676
} else {
77-
sendDeleteProgress(opts, f)
77+
sendDeleteProgress(opts, f, true)
7878
}
7979
}
8080

@@ -85,27 +85,29 @@ func deleteDir(path string, opts DeleteOptions) error {
8585
opts.Errors.Add(d, err)
8686
}
8787
} else {
88-
sendDeleteProgress(opts, d)
88+
sendDeleteProgress(opts, d, false)
8989
}
9090
}
9191

9292
if err := os.Remove(path); err != nil {
9393
return err
9494
}
95-
sendDeleteProgress(opts, path)
95+
sendDeleteProgress(opts, path, false)
9696

9797
return nil
9898
}
9999

100-
func sendDeleteProgress(opts DeleteOptions, path string) {
101-
if opts.Program == nil {
102-
return
103-
}
100+
func sendDeleteProgress(opts DeleteOptions, path string, isFile bool) {
104101
var files int64
105102
if opts.CumulFiles != nil {
106-
*opts.CumulFiles++
103+
if isFile {
104+
*opts.CumulFiles++
105+
}
107106
files = *opts.CumulFiles
108107
}
108+
if opts.Program == nil {
109+
return
110+
}
109111
opts.Program.Send(ui.ProgressMsg{
110112
FilesDone: files,
111113
CurrentFile: path,

internal/ops/move.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ type MoveOptions struct {
1919
}
2020

2121
func Move(src, dst string, opts MoveOptions) error {
22+
if samePath(src, dst) {
23+
return fmt.Errorf("%s and %s are the same file", src, dst)
24+
}
2225
err := os.Rename(src, dst)
2326
if err == nil {
2427
return nil

0 commit comments

Comments
 (0)