···55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7788+## [0.1.8] - 2026-04-05
99+1010+### Added
1111+- Copy interface: `Sandbox.upload/4`, `Sandbox.download/4`, `Sandbox.copy_to/5` for transferring files between local paths and sandboxes, or between two sandboxes
1212+- `Pocketenv.Copy` module handling tar.gz compression/decompression (via `:erl_tar`), multipart upload to storage, and binary download from storage
1313+- `Pocketenv.Ignore` module: gitignore-style filtering applied during directory compression, respecting `.pocketenvignore`, `.gitignore`, `.npmignore`, and `.dockerignore` files at any depth; supports `*`, `**`, `?`, `[...]` globs, trailing `/` directory patterns, and `!` negation
1414+- `POCKETENV_STORAGE_URL` environment variable and `:storage_url` app config key for overriding the storage endpoint (default: `https://sandbox.pocketenv.io`)
1515+816## [0.1.7] - 2026-04-02
9171018### Fixed
···5563- MIT License
5664- Package description in `mix.exs`
57656666+[0.1.8]: https://github.com/pocketenv-io/pocketenv-elixir/compare/v0.1.7...v0.1.8
5867[0.1.7]: https://github.com/pocketenv-io/pocketenv-elixir/compare/v0.1.6...v0.1.7
5968[0.1.6]: https://github.com/pocketenv-io/pocketenv-elixir/compare/v0.1.5...v0.1.6
6069[0.1.5]: https://github.com/pocketenv-io/pocketenv-elixir/compare/v0.1.4...v0.1.5
···375375 end
376376377377 # ---------------------------------------------------------------------------
378378+ # Pocketenv.Ignore
379379+ # ---------------------------------------------------------------------------
380380+381381+ describe "Pocketenv.Ignore.ignored?/2 – simple name patterns" do
382382+ test "ignores an exact filename match" do
383383+ ctx = build_contexts("", ".DS_Store")
384384+ assert Pocketenv.Ignore.ignored?(ctx, ".DS_Store")
385385+ end
386386+387387+ test "ignores the filename at any depth" do
388388+ ctx = build_contexts("", ".DS_Store")
389389+ assert Pocketenv.Ignore.ignored?(ctx, "a/b/.DS_Store")
390390+ end
391391+392392+ test "does not ignore a non-matching file" do
393393+ ctx = build_contexts("", ".DS_Store")
394394+ refute Pocketenv.Ignore.ignored?(ctx, "README.md")
395395+ end
396396+ end
397397+398398+ describe "Pocketenv.Ignore.ignored?/2 – glob patterns" do
399399+ test "*.log matches a log file at root" do
400400+ ctx = build_contexts("", "*.log")
401401+ assert Pocketenv.Ignore.ignored?(ctx, "debug.log")
402402+ end
403403+404404+ test "*.log matches a log file at any depth via suffix check" do
405405+ ctx = build_contexts("", "*.log")
406406+ assert Pocketenv.Ignore.ignored?(ctx, "logs/debug.log")
407407+ assert Pocketenv.Ignore.ignored?(ctx, "a/b/c/server.log")
408408+ end
409409+410410+ test "*.log does not match unrelated extensions" do
411411+ ctx = build_contexts("", "*.log")
412412+ refute Pocketenv.Ignore.ignored?(ctx, "app.ex")
413413+ refute Pocketenv.Ignore.ignored?(ctx, "logs/README.md")
414414+ end
415415+416416+ test "? matches exactly one non-slash character" do
417417+ ctx = build_contexts("", "foo?.txt")
418418+ assert Pocketenv.Ignore.ignored?(ctx, "fooa.txt")
419419+ refute Pocketenv.Ignore.ignored?(ctx, "foo.txt")
420420+ refute Pocketenv.Ignore.ignored?(ctx, "fooab.txt")
421421+ end
422422+ end
423423+424424+ describe "Pocketenv.Ignore.ignored?/2 – directory patterns" do
425425+ test "directory name ignores the directory itself" do
426426+ ctx = build_contexts("", "node_modules")
427427+ assert Pocketenv.Ignore.ignored?(ctx, "node_modules")
428428+ end
429429+430430+ test "directory name ignores all contents" do
431431+ ctx = build_contexts("", "node_modules")
432432+ assert Pocketenv.Ignore.ignored?(ctx, "node_modules/index.js")
433433+ assert Pocketenv.Ignore.ignored?(ctx, "node_modules/lodash/index.js")
434434+ end
435435+436436+ test "directory name with trailing slash ignores all contents" do
437437+ ctx = build_contexts("", "dist/")
438438+ assert Pocketenv.Ignore.ignored?(ctx, "dist/bundle.js")
439439+ assert Pocketenv.Ignore.ignored?(ctx, "dist/a/b.js")
440440+ end
441441+442442+ test "directory pattern does not match unrelated names" do
443443+ ctx = build_contexts("", "node_modules")
444444+ refute Pocketenv.Ignore.ignored?(ctx, "src/index.js")
445445+ refute Pocketenv.Ignore.ignored?(ctx, "not_node_modules/foo.js")
446446+ end
447447+ end
448448+449449+ describe "Pocketenv.Ignore.ignored?/2 – double-star patterns" do
450450+ test "**/.DS_Store matches at any depth" do
451451+ ctx = build_contexts("", "**/.DS_Store")
452452+ assert Pocketenv.Ignore.ignored?(ctx, ".DS_Store")
453453+ assert Pocketenv.Ignore.ignored?(ctx, "a/.DS_Store")
454454+ assert Pocketenv.Ignore.ignored?(ctx, "a/b/c/.DS_Store")
455455+ end
456456+457457+ test "build/** ignores everything under build/" do
458458+ ctx = build_contexts("", "build/**")
459459+ assert Pocketenv.Ignore.ignored?(ctx, "build/app.js")
460460+ assert Pocketenv.Ignore.ignored?(ctx, "build/nested/app.js")
461461+ end
462462+463463+ test "build/** does not match sibling directories" do
464464+ ctx = build_contexts("", "build/**")
465465+ refute Pocketenv.Ignore.ignored?(ctx, "src/app.js")
466466+ end
467467+ end
468468+469469+ describe "Pocketenv.Ignore.ignored?/2 – negation" do
470470+ test "! un-ignores a previously matched path" do
471471+ ctx = build_contexts("", "*.log\n!important.log")
472472+ assert Pocketenv.Ignore.ignored?(ctx, "debug.log")
473473+ refute Pocketenv.Ignore.ignored?(ctx, "important.log")
474474+ end
475475+476476+ test "last matching pattern wins" do
477477+ # ignore all, then keep .env, then ignore .env again
478478+ ctx = build_contexts("", "*.env\n!.env\n.env")
479479+ assert Pocketenv.Ignore.ignored?(ctx, ".env")
480480+ end
481481+ end
482482+483483+ describe "Pocketenv.Ignore.ignored?/2 – sub-directory context" do
484484+ test "a context scoped to a subdirectory only applies within that directory" do
485485+ ctx = build_contexts("src", "*.log")
486486+ assert Pocketenv.Ignore.ignored?(ctx, "src/debug.log")
487487+ refute Pocketenv.Ignore.ignored?(ctx, "lib/debug.log")
488488+ end
489489+ end
490490+491491+ describe "Pocketenv.Ignore.ignored?/2 – comments and blank lines" do
492492+ test "lines starting with # are ignored" do
493493+ ctx = build_contexts("", "# this is a comment\n*.log")
494494+ assert Pocketenv.Ignore.ignored?(ctx, "app.log")
495495+ end
496496+497497+ test "blank lines are ignored" do
498498+ ctx = build_contexts("", "\n\n*.log\n\n")
499499+ assert Pocketenv.Ignore.ignored?(ctx, "app.log")
500500+ end
501501+ end
502502+503503+ describe "Pocketenv.Ignore.load/1" do
504504+ test "loads patterns from .gitignore in a temp directory" do
505505+ dir = System.tmp_dir!() |> Path.join("pocketenv_ignore_test_#{System.unique_integer([:positive])}")
506506+ File.mkdir_p!(dir)
507507+ File.write!(Path.join(dir, ".gitignore"), "node_modules\n*.log\n")
508508+ on_exit(fn -> File.rm_rf(dir) end)
509509+510510+ contexts = Pocketenv.Ignore.load(dir)
511511+ assert length(contexts) == 1
512512+513513+ assert Pocketenv.Ignore.ignored?(contexts, "node_modules/index.js")
514514+ assert Pocketenv.Ignore.ignored?(contexts, "app.log")
515515+ refute Pocketenv.Ignore.ignored?(contexts, "src/app.ex")
516516+ end
517517+518518+ test "loads patterns from nested ignore files with correct scope" do
519519+ dir = System.tmp_dir!() |> Path.join("pocketenv_ignore_nested_#{System.unique_integer([:positive])}")
520520+ sub = Path.join(dir, "packages/ui")
521521+ File.mkdir_p!(sub)
522522+ File.write!(Path.join(dir, ".gitignore"), "*.log\n")
523523+ File.write!(Path.join(sub, ".gitignore"), "dist\n")
524524+ on_exit(fn -> File.rm_rf(dir) end)
525525+526526+ contexts = Pocketenv.Ignore.load(dir)
527527+ assert length(contexts) == 2
528528+529529+ # Root .gitignore applies everywhere
530530+ assert Pocketenv.Ignore.ignored?(contexts, "app.log")
531531+ assert Pocketenv.Ignore.ignored?(contexts, "packages/ui/app.log")
532532+533533+ # Nested .gitignore only applies within its directory
534534+ assert Pocketenv.Ignore.ignored?(contexts, "packages/ui/dist/bundle.js")
535535+ refute Pocketenv.Ignore.ignored?(contexts, "dist/bundle.js")
536536+ end
537537+538538+ test "returns empty list for a directory with no ignore files" do
539539+ dir = System.tmp_dir!() |> Path.join("pocketenv_no_ignore_#{System.unique_integer([:positive])}")
540540+ File.mkdir_p!(dir)
541541+ File.write!(Path.join(dir, "README.md"), "hello")
542542+ on_exit(fn -> File.rm_rf(dir) end)
543543+544544+ assert Pocketenv.Ignore.load(dir) == []
545545+ end
546546+547547+ test "skips unreadable ignore files gracefully" do
548548+ # An empty context list is acceptable when no files can be read
549549+ contexts = Pocketenv.Ignore.load("/nonexistent/path")
550550+ assert contexts == []
551551+ end
552552+ end
553553+554554+ # Builds a single ignore context directly from a pattern string,
555555+ # bypassing filesystem access.
556556+ defp build_contexts(dir, pattern_content) do
557557+ contexts = Pocketenv.Ignore.load_from_string(dir, pattern_content)
558558+ contexts
559559+ end
560560+561561+ # ---------------------------------------------------------------------------
562562+ # Pocketenv.Copy.storage_url/0
563563+ # ---------------------------------------------------------------------------
564564+565565+ describe "Pocketenv.Copy.storage_url/0" do
566566+ test "returns the default storage URL when no config is present" do
567567+ prev = Application.get_env(:pocketenv_ex, :storage_url)
568568+ Application.delete_env(:pocketenv_ex, :storage_url)
569569+ System.delete_env("POCKETENV_STORAGE_URL")
570570+ on_exit(fn -> if prev, do: Application.put_env(:pocketenv_ex, :storage_url, prev) end)
571571+572572+ assert Pocketenv.Copy.storage_url() == "https://sandbox.pocketenv.io"
573573+ end
574574+575575+ test "respects the POCKETENV_STORAGE_URL environment variable" do
576576+ prev = Application.get_env(:pocketenv_ex, :storage_url)
577577+ Application.delete_env(:pocketenv_ex, :storage_url)
578578+ on_exit(fn -> if prev, do: Application.put_env(:pocketenv_ex, :storage_url, prev) end)
579579+580580+ System.put_env("POCKETENV_STORAGE_URL", "https://custom.storage.example.com")
581581+ on_exit(fn -> System.delete_env("POCKETENV_STORAGE_URL") end)
582582+583583+ assert Pocketenv.Copy.storage_url() == "https://custom.storage.example.com"
584584+ end
585585+586586+ test "application config takes precedence over environment variable" do
587587+ System.put_env("POCKETENV_STORAGE_URL", "https://env.storage.example.com")
588588+ on_exit(fn -> System.delete_env("POCKETENV_STORAGE_URL") end)
589589+590590+ Application.put_env(:pocketenv_ex, :storage_url, "https://config.storage.example.com")
591591+ on_exit(fn -> Application.delete_env(:pocketenv_ex, :storage_url) end)
592592+593593+ assert Pocketenv.Copy.storage_url() == "https://config.storage.example.com"
594594+ end
595595+ end
596596+597597+ # ---------------------------------------------------------------------------
598598+ # Sandbox copy methods — API surface
599599+ # ---------------------------------------------------------------------------
600600+601601+ describe "Sandbox copy methods – arities" do
602602+ setup do
603603+ fns = Sandbox.__info__(:functions)
604604+ {:ok, fns: fns}
605605+ end
606606+607607+ test "upload/4 is defined", %{fns: fns} do
608608+ assert {:upload, 3} in fns
609609+ assert {:upload, 4} in fns
610610+ end
611611+612612+ test "download/4 is defined", %{fns: fns} do
613613+ assert {:download, 3} in fns
614614+ assert {:download, 4} in fns
615615+ end
616616+617617+ test "copy_to/5 is defined", %{fns: fns} do
618618+ assert {:copy_to, 4} in fns
619619+ assert {:copy_to, 5} in fns
620620+ end
621621+ end
622622+623623+ describe "Sandbox copy methods – {:ok, struct} passthrough" do
624624+ test "upload/4 accepts {:ok, %Sandbox{}} and does not raise FunctionClauseError" do
625625+ sandbox = %Sandbox{id: "sbx-1", name: "test", status: :running, installs: 0}
626626+ tmp = Path.join(System.tmp_dir!(), "pocketenv_test_upload_#{:erlang.unique_integer([:positive])}.txt")
627627+ File.write!(tmp, "hello")
628628+ on_exit(fn -> File.rm(tmp) end)
629629+630630+ # {:ok, struct} must not raise FunctionClauseError; it will fail at the HTTP layer
631631+ assert match?({:error, _}, Sandbox.upload({:ok, sandbox}, tmp, "/remote"))
632632+ end
633633+634634+ test "download/4 accepts {:ok, %Sandbox{}} and does not raise FunctionClauseError" do
635635+ sandbox = %Sandbox{id: "sbx-1", name: "test", status: :running, installs: 0}
636636+ # Fails at HTTP layer (no token / no network), not at pattern match
637637+ assert match?({:error, _}, Sandbox.download({:ok, sandbox}, "/remote", System.tmp_dir!()))
638638+ end
639639+640640+ test "copy_to/5 accepts {:ok, %Sandbox{}} and does not raise FunctionClauseError" do
641641+ sandbox = %Sandbox{id: "sbx-1", name: "test", status: :running, installs: 0}
642642+ assert match?({:error, _}, Sandbox.copy_to({:ok, sandbox}, "sbx-2", "/src", "/dest"))
643643+ end
644644+ end
645645+646646+ describe "Sandbox copy methods – error propagation" do
647647+ test "upload/4 raises FunctionClauseError on {:error, reason}" do
648648+ assert_raise FunctionClauseError, fn ->
649649+ Sandbox.upload({:error, :not_found}, "/local", "/remote")
650650+ end
651651+ end
652652+653653+ test "download/4 raises FunctionClauseError on {:error, reason}" do
654654+ assert_raise FunctionClauseError, fn ->
655655+ Sandbox.download({:error, :not_found}, "/remote", "/local")
656656+ end
657657+ end
658658+659659+ test "copy_to/5 raises FunctionClauseError on {:error, reason}" do
660660+ assert_raise FunctionClauseError, fn ->
661661+ Sandbox.copy_to({:error, :not_found}, "sbx-2", "/src", "/dest")
662662+ end
663663+ end
664664+ end
665665+666666+ # ---------------------------------------------------------------------------
378667 # Pocketenv public API surface
379668 # ---------------------------------------------------------------------------
380669