Repository: cookiecutter/cookiecutter-django

Issue:
.envs/* exclusion in .gitignore may not be respected on Windows
## What happened?

After working through "Getting Up and Running Locally With Docker", on a Windows machine, I ran git init, which triggered the addition of .env and .envs/* to .gitignore. 

However, .envs/* was not ignored. Examining .gitignore, the two added lines are terminated with CR, whereas all other lines are terminated CRLF. As such, the ignore was not respected in this instance. 

## What should've happened instead?

 .envs/* should be ignored, preventing production secrets from being exposed in source/version control. If I edit .gitignore manually and add line breaks, the ignore is respected.

## Additional details

<!-- To assist you best, please include commands that you've run, options you've selected and any relevant logs -->

* Host system configuration: 
    * Cookiecutter 1.7.3
    * Microsoft Windows 10 Pro, 19042
    * Python3.8.2
    * Docker  20.10.7
  
* Options selected and/or [replay file](https://cookiecutter.readthedocs.io/en/latest/advanced/replay.html):
  
    ```json
    {
  "cookiecutter": {
    "windows": "y",
    "use_pycharm": "n",
    "use_docker": "y",
    "postgresql_version": "13.2",
    "js_task_runner": "None",
    "cloud_provider": "None",
    "mail_service": "Sendgrid",
    "use_async": "n",
    "use_drf": "n",
    "custom_bootstrap_compilation": "n",
    "use_compressor": "n",
    "use_celery": "n",
    "use_mailhog": "n",
    "use_sentry": "n",
    "use_whitenoise": "y",
    "use_heroku": "n",
    "ci_tool": "None",
    "keep_local_envs_in_vcs": "n",
    "debug": "n",
    "_template": "gh:pydanny/cookiecutter-django"
  }
}
    ```
<summary>
Logs:
<details>
<pre>
$ cookiecutter https://github.com/pydanny/cookiecutter-django
project_name [Project Name]: ...
</pre>
</details>
</summary>



Discussion:
This function seems to be responsible for the addition to .gitignore.

https://github.com/pydanny/cookiecutter-django/blob/103cc9f2ffb594796b98b2916ecec043b8b5dd9e/hooks/post_gen_project.py#L130
That's interesting. Could it be because of the mode we're in? We're using `os.linesep` which should work just fine...
I have run into the same issue, not quite sure if I got it correctly, but in my mind it should actually have: 
".envs/", instead of: ".envs/*" - as according to this from the Git Docu, it would ignore only direct childs of /envs directory otherwise, but have as an example the path ".envs/.production/.postgres" included: 
> The pattern "foo/*", matches "foo/test.json" (a regular file), "foo/bar" (a directory), but it does not match "foo/bar/hello.c" (a regular file), as the asterisk in the pattern does not match "bar/hello.c" which has a slash in it.

It did do the trick for me, to have the ".envs" directory ignored. 
I am having trouble to re-add the .local directory though - this entry appears not to work for me "!/.envs/.local"
@ArtSphere maybe try clearing the git cache and re add the new line in the gitignore
> This function seems to be responsible for the addition to .gitignore.
> 
> https://github.com/pydanny/cookiecutter-django/blob/103cc9f2ffb594796b98b2916ecec043b8b5dd9e/hooks/post_gen_project.py#L130

That's interesting... I'm wondering why we're doing that instead of the Jinja templating we do in the rest of the file. Has anyone tried it? That might fix it 
> That's interesting. Could it be because of the mode we're in? We're using `os.linesep` which should work just fine...

Looks like you're right! From [the docs](https://docs.python.org/3/library/os.html#os.linesep):

> Do not use os.linesep as a line terminator when writing files opened in text mode (the default); use a single '\n' instead, on all platforms.

Resolving patch:
diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py
--- a/hooks/post_gen_project.py
+++ b/hooks/post_gen_project.py
@@ -139,13 +139,6 @@ def remove_dotgithub_folder():
     shutil.rmtree(".github")
 
 
-def append_to_project_gitignore(path):
-    gitignore_file_path = ".gitignore"
-    with open(gitignore_file_path, "a") as gitignore_file:
-        gitignore_file.write(path)
-        gitignore_file.write(os.linesep)
-
-
 def generate_random_string(
     length, using_digits=False, using_ascii_letters=False, using_punctuation=False
 ):
@@ -260,10 +253,10 @@ def set_celery_flower_password(file_path, value=None):
     return celery_flower_password
 
 
-def append_to_gitignore_file(s):
+def append_to_gitignore_file(ignored_line):
     with open(".gitignore", "a") as gitignore_file:
-        gitignore_file.write(s)
-        gitignore_file.write(os.linesep)
+        gitignore_file.write(ignored_line)
+        gitignore_file.write("\n")
 
 
 def set_flags_in_envs(postgres_user, celery_flower_user, debug=False):


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py
--- a/tests/test_cookiecutter_generation.py
+++ b/tests/test_cookiecutter_generation.py
@@ -1,14 +1,20 @@
 import os
 import re
+import sys
 
 import pytest
-import sh
+
+try:
+    import sh
+except (ImportError, ModuleNotFoundError):
+    sh = None  # sh doesn't support Windows
 import yaml
 from binaryornot.check import is_binary
 from cookiecutter.exceptions import FailedHookException
 
 PATTERN = r"{{(\s?cookiecutter)[.](.*?)}}"
 RE_OBJ = re.compile(PATTERN)
+IS_WINDOWS = sys.platform.startswith("win")
 
 
 @pytest.fixture
@@ -111,7 +117,7 @@ def context():
 
 
 def _fixture_id(ctx):
-    """Helper to get a user friendly test name from the parametrized context."""
+    """Helper to get a user-friendly test name from the parametrized context."""
     return "-".join(f"{key}:{value}" for key, value in ctx.items())
 
 
@@ -151,6 +157,7 @@ def test_project_generation(cookies, context, context_override):
     check_paths(paths)
 
 
+@pytest.mark.skipif(IS_WINDOWS, reason="sh doesn't support windows")
 @pytest.mark.parametrize("context_override", SUPPORTED_COMBINATIONS, ids=_fixture_id)
 def test_flake8_passes(cookies, context_override):
     """Generated project should pass flake8."""
@@ -162,6 +169,7 @@ def test_flake8_passes(cookies, context_override):
         pytest.fail(e.stdout.decode())
 
 
+@pytest.mark.skipif(IS_WINDOWS, reason="sh doesn't support windows")
 @pytest.mark.parametrize("context_override", SUPPORTED_COMBINATIONS, ids=_fixture_id)
 def test_black_passes(cookies, context_override):
     """Generated project should pass black."""
@@ -265,7 +273,7 @@ def test_github_invokes_linter_and_pytest(
 
 @pytest.mark.parametrize("slug", ["project slug", "Project_Slug"])
 def test_invalid_slug(cookies, context, slug):
-    """Invalid slug should failed pre-generation hook."""
+    """Invalid slug should fail pre-generation hook."""
     context.update({"project_slug": slug})
 
     result = cookies.bake(extra_context=context)
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
new file mode 100644
--- /dev/null
+++ b/tests/test_hooks.py
@@ -0,0 +1,28 @@
+"""Unit tests for the hooks"""
+import os
+from pathlib import Path
+
+import pytest
+
+from hooks.post_gen_project import append_to_gitignore_file
+
+
+@pytest.fixture()
+def working_directory(tmp_path):
+    prev_cwd = Path.cwd()
+    os.chdir(tmp_path)
+    try:
+        yield tmp_path
+    finally:
+        os.chdir(prev_cwd)
+
+
+def test_append_to_gitignore_file(working_directory):
+    gitignore_file = working_directory / ".gitignore"
+    gitignore_file.write_text("node_modules/\n")
+    append_to_gitignore_file(".envs/*")
+    linesep = os.linesep.encode()
+    assert (
+        gitignore_file.read_bytes() == b"node_modules/" + linesep + b".envs/*" + linesep
+    )
+    assert gitignore_file.read_text() == "node_modules/\n.envs/*\n"