Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 9 additions & 18 deletions src/nightshift/cli/commands/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,24 @@
import httpx

from nightshift.cli.config import get_auth_headers, get_url

# Patterns to exclude from the archive
EXCLUDE_PATTERNS = {".git", "__pycache__", ".venv", ".env", "*.pyc", "node_modules", ".ruff_cache"}


def _should_exclude(path: str) -> bool:
"""Check if a path should be excluded from the archive."""
parts = path.split(os.sep)
for part in parts:
if part in EXCLUDE_PATTERNS:
return True
for pattern in EXCLUDE_PATTERNS:
if pattern.startswith("*") and part.endswith(pattern[1:]):
return True
return False
from nightshift.nsignore import read_nsignore, should_exclude


def _make_archive(project_dir: str) -> bytes:
"""Create a tar.gz archive of the project directory."""
"""Create a tar.gz archive of the project directory.

Patterns from a .nsignore file in *project_dir* (merged with defaults)
are used to skip files and directories.
"""
patterns = read_nsignore(project_dir)
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for root, dirs, files in os.walk(project_dir):
# Filter out excluded directories in-place
dirs[:] = [d for d in dirs if not _should_exclude(d)]
dirs[:] = [d for d in dirs if not should_exclude(d, patterns)]

for f in files:
if _should_exclude(f):
if should_exclude(f, patterns):
continue
full_path = os.path.join(root, f)
arcname = os.path.relpath(full_path, project_dir)
Expand Down
42 changes: 42 additions & 0 deletions src/nightshift/nsignore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Reader for .nsignore files — determines which files to skip in listing and deploy."""

from __future__ import annotations

import os

# Patterns to exclude from the archive
DEFAULT_EXCLUDE_PATTERNS = {".git", "__pycache__", ".venv", ".env", "*.pyc", "node_modules", ".ruff_cache"}


def read_nsignore(directory: str) -> set[str]:
"""Read .nsignore from *directory* and return the combined set of patterns.

Lines starting with '#' and empty lines are ignored.
"""
patterns: set[str] = set(DEFAULT_EXCLUDE_PATTERNS)
nsignore_path = os.path.join(directory, ".nsignore")
if os.path.isfile(nsignore_path):
with open(nsignore_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
patterns.add(line)
return patterns


def should_exclude(name: str, patterns: set[str]) -> bool:
"""Return True if *name* matches any pattern in *patterns*.

Supports:
- Exact name matches (e.g. ``.git``, ``node_modules``)
- Wildcard suffix patterns (e.g. ``*.pyc``, ``*.log``)
"""
# Handle path components (e.g. "foo/__pycache__/bar.pyc")
parts = name.replace("\\", "/").split("/")
for part in parts:
if part in patterns:
return True
for pattern in patterns:
if pattern.startswith("*") and part.endswith(pattern[1:]):
return True
return False
7 changes: 5 additions & 2 deletions src/nightshift/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import Any

from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from nightshift.nsignore import read_nsignore, should_exclude
from fastapi.responses import FileResponse, JSONResponse
from sse_starlette.sse import EventSourceResponse

Expand Down Expand Up @@ -379,10 +380,10 @@ async def list_workspace(
if not os.path.isdir(ws_dir):
return {"files": []}

skip = {".git", "__pycache__", ".venv", "node_modules", ".ruff_cache"}
skip = read_nsignore(ws_dir)
files: list[dict[str, Any]] = []
for dirpath, dirnames, filenames in os.walk(ws_dir):
dirnames[:] = [d for d in dirnames if d not in skip]
dirnames[:] = [d for d in dirnames if not should_exclude(d, skip)]
rel_dir = os.path.relpath(dirpath, ws_dir)
# Include directories (except the root itself)
if rel_dir != ".":
Expand All @@ -394,6 +395,8 @@ async def list_workspace(
"modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
})
for fname in filenames:
if should_exclude(fname, skip):
continue
full = os.path.join(dirpath, fname)
rel = os.path.relpath(full, ws_dir)
st = os.stat(full)
Expand Down
145 changes: 145 additions & 0 deletions tests/test_nsignore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Tests for the .nsignore file reader."""

from __future__ import annotations

import io
import tarfile

import pytest

from nightshift.nsignore import DEFAULT_EXCLUDE_PATTERNS, read_nsignore, should_exclude


# ── read_nsignore ──────────────────────────────────────────────


class TestReadNsignore:
def test_returns_defaults_when_no_file(self, tmp_path):
patterns = read_nsignore(str(tmp_path))
assert patterns == set(DEFAULT_EXCLUDE_PATTERNS)

def test_merges_user_patterns_with_defaults(self, tmp_path):
(tmp_path / ".nsignore").write_text("secrets/\n*.log\n")
patterns = read_nsignore(str(tmp_path))
assert "secrets/" in patterns
assert "*.log" in patterns
# defaults still present
assert ".git" in patterns
assert "__pycache__" in patterns

def test_ignores_comment_lines(self, tmp_path):
(tmp_path / ".nsignore").write_text("# this is a comment\nbuild/\n")
patterns = read_nsignore(str(tmp_path))
assert "# this is a comment" not in patterns
assert "build/" in patterns

def test_ignores_blank_lines(self, tmp_path):
(tmp_path / ".nsignore").write_text("\n\nbuild/\n\n")
patterns = read_nsignore(str(tmp_path))
assert "" not in patterns
assert "build/" in patterns

def test_strips_whitespace_from_patterns(self, tmp_path):
(tmp_path / ".nsignore").write_text(" dist/ \n")
patterns = read_nsignore(str(tmp_path))
assert "dist/" in patterns
assert " dist/ " not in patterns


# ── should_exclude ─────────────────────────────────────────────


class TestShouldExclude:
def test_exact_name_match(self):
patterns = {".git", "node_modules"}
assert should_exclude(".git", patterns) is True
assert should_exclude("node_modules", patterns) is True
assert should_exclude("src", patterns) is False

def test_wildcard_suffix_match(self):
patterns = {"*.pyc", "*.log"}
assert should_exclude("module.pyc", patterns) is True
assert should_exclude("app.log", patterns) is True
assert should_exclude("module.py", patterns) is False

def test_path_component_match(self):
patterns = {"__pycache__"}
assert should_exclude("src/__pycache__/mod.pyc", patterns) is True
assert should_exclude("src/utils.py", patterns) is False

def test_no_match_returns_false(self):
patterns = {".git", "*.pyc"}
assert should_exclude("main.py", patterns) is False
assert should_exclude("README.md", patterns) is False


# ── integration with _make_archive ────────────────────────────


def _archive_dir(project_dir: str) -> dict[str, str]:
"""Build a tar.gz of *project_dir* using read_nsignore/should_exclude and return its contents."""
import os

patterns = read_nsignore(project_dir)
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for root, dirs, files in os.walk(project_dir):
dirs[:] = [d for d in dirs if not should_exclude(d, patterns)]
for f in files:
if should_exclude(f, patterns):
continue
full_path = os.path.join(root, f)
arcname = os.path.relpath(full_path, project_dir)
tar.add(full_path, arcname=arcname)
buf.seek(0)
result: dict[str, str] = {}
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
for member in tar.getmembers():
if member.isfile():
fh = tar.extractfile(member)
if fh:
result[member.name] = fh.read().decode()
return result


class TestNsignoreInDeploy:
"""Verify that .nsignore patterns are honoured during archive creation."""

def test_nsignore_excludes_custom_dir(self, tmp_path):
(tmp_path / "main.py").write_text("# entry")
(tmp_path / "dist").mkdir()
(tmp_path / "dist" / "bundle.js").write_text("bundle")
(tmp_path / ".nsignore").write_text("dist\n")

files = _archive_dir(str(tmp_path))
assert "main.py" in files
assert not any("dist" in name for name in files)

def test_nsignore_excludes_wildcard_files(self, tmp_path):
(tmp_path / "main.py").write_text("# entry")
(tmp_path / "output.log").write_text("log data")
(tmp_path / ".nsignore").write_text("*.log\n")

files = _archive_dir(str(tmp_path))
assert "main.py" in files
assert "output.log" not in files

def test_defaults_always_excluded_even_without_nsignore(self, tmp_path):
(tmp_path / "main.py").write_text("# entry")
pycache = tmp_path / "__pycache__"
pycache.mkdir()
(pycache / "main.cpython-313.pyc").write_text("bytecode")

files = _archive_dir(str(tmp_path))
assert "main.py" in files
assert not any("__pycache__" in name for name in files)

def test_nsignore_file_itself_not_excluded(self, tmp_path):
"""The .nsignore file should appear in the archive (it's a config file)."""
(tmp_path / "main.py").write_text("# entry")
(tmp_path / ".nsignore").write_text("dist\n")

files = _archive_dir(str(tmp_path))
assert "main.py" in files
# .nsignore is not in DEFAULT_PATTERNS so it should be included
assert ".nsignore" in files