├── .credo.exs ├── .dialyzer_ignore.exs ├── .formatter.exs ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── feature-request.md │ └── to-do.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── credo-format.yml │ └── test-coverage.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bench ├── README.md └── xgit │ └── repository │ └── working_tree │ └── parse_index_file │ └── from_iodevice.exs ├── branding └── xgit-logo.png ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── coveralls.json ├── lib ├── xgit.ex └── xgit │ ├── README.md │ ├── commit.ex │ ├── config.ex │ ├── config_entry.ex │ ├── config_file.ex │ ├── content_source.ex │ ├── dir_cache.ex │ ├── file_content_source.ex │ ├── file_mode.ex │ ├── file_path.ex │ ├── object.ex │ ├── object_id.ex │ ├── object_type.ex │ ├── person_ident.ex │ ├── ref.ex │ ├── repository.ex │ ├── repository │ ├── in_memory.ex │ ├── invalid_repository_error.ex │ ├── on_disk.ex │ ├── plumbing.ex │ ├── storage.ex │ ├── test │ │ ├── config_test.ex │ │ └── ref_test.ex │ └── working_tree.ex │ ├── tag.ex │ ├── tree.ex │ └── util │ ├── README.md │ ├── comparison.ex │ ├── file_utils.ex │ ├── force_coverage.ex │ ├── nb.ex │ ├── observed_file.ex │ ├── parse_charlist.ex │ ├── parse_decimal.ex │ ├── parse_header.ex │ ├── shared_test_case.ex │ ├── trailing_hash_device.ex │ └── unzip_stream.ex ├── mix.exs ├── mix.lock └── test ├── fixtures ├── LICENSE_blob.zip └── test_content.zip ├── support ├── folder_diff.ex ├── not_valid.ex └── test │ ├── on_disk_repo_test_case.ex │ ├── temp_dir_test_case.ex │ └── test_file_utils.ex ├── test_helper.exs └── xgit ├── commit_test.exs ├── config_entry_test.exs ├── config_file_test.exs ├── config_test.exs ├── content_source_test.exs ├── dir_cache ├── entry_test.exs ├── from_iodevice_test.exs └── to_iodevice_test.exs ├── dir_cache_test.exs ├── file_content_source_test.exs ├── file_mode_test.exs ├── file_path_test.exs ├── object_id_test.exs ├── object_test.exs ├── object_type_test.exs ├── person_ident_test.exs ├── ref_test.exs ├── repository ├── default_working_tree_test.exs ├── in_memory │ ├── config_test.exs │ ├── get_object_test.exs │ ├── has_all_object_ids_test.exs │ ├── put_loose_object_test.exs │ └── ref_test.exs ├── on_disk │ ├── config_test.exs │ ├── create_test.exs │ ├── get_object_test.exs │ ├── has_all_object_ids_test.exs │ ├── put_loose_object_test.exs │ └── ref_test.exs ├── on_disk_test.exs ├── plumbing │ ├── cat_file_commit_test.exs │ ├── cat_file_tag_test.exs │ ├── cat_file_test.exs │ ├── cat_file_tree_test.exs │ ├── commit_tree_test.exs │ ├── delete_symbolic_ref_test.exs │ ├── get_symbolic_ref_test.exs │ ├── hash_object_test.exs │ ├── ls_files_stage_test.exs │ ├── put_symbolic_ref_test.exs │ ├── read_tree_test.exs │ ├── update_info_cache_info_test.exs │ ├── update_ref_test.exs │ └── write_tree_test.exs ├── storage_test.exs ├── tag_test.exs ├── working_tree │ ├── dir_cache_test.exs │ ├── read_tree_test.exs │ ├── reset_dir_cache_test.exs │ ├── update_dir_cache_test.exs │ └── write_tree_test.exs └── working_tree_test.exs ├── support └── folder_diff_test.exs ├── tag_test.exs ├── tree └── entry_test.exs ├── tree_test.exs └── util ├── file_utils_test.exs ├── nb_test.exs ├── observed_file_test.exs ├── parse_charlist_test.exs ├── parse_decimal_test.exs ├── parse_header_test.exs ├── trailing_hash_device_test.exs └── unzip_stream_test.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | 6 | files: %{ 7 | included: ["lib/", "test/"], 8 | excluded: [~r"/_build/", ~r"/deps/"] 9 | }, 10 | 11 | requires: [], 12 | strict: true, 13 | 14 | color: true, 15 | 16 | checks: [ 17 | # Consistency Checks 18 | 19 | {Credo.Check.Consistency.ExceptionNames, []}, 20 | {Credo.Check.Consistency.LineEndings, []}, 21 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 22 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 23 | {Credo.Check.Consistency.SpaceInParentheses, []}, 24 | {Credo.Check.Consistency.TabsOrSpaces, []}, 25 | 26 | # Design Checks 27 | 28 | {Credo.Check.Design.AliasUsage, 29 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 30 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 31 | {Credo.Check.Design.TagFIXME, []}, 32 | 33 | # Readability Checks 34 | 35 | {Credo.Check.Readability.AliasOrder, []}, 36 | {Credo.Check.Readability.FunctionNames, []}, 37 | {Credo.Check.Readability.LargeNumbers, []}, 38 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 39 | {Credo.Check.Readability.ModuleAttributeNames, []}, 40 | {Credo.Check.Readability.ModuleDoc, []}, 41 | {Credo.Check.Readability.ModuleNames, []}, 42 | {Credo.Check.Readability.ParenthesesInCondition, []}, 43 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 44 | {Credo.Check.Readability.PredicateFunctionNames, []}, 45 | {Credo.Check.Readability.PreferImplicitTry, []}, 46 | {Credo.Check.Readability.RedundantBlankLines, []}, 47 | {Credo.Check.Readability.Semicolons, []}, 48 | {Credo.Check.Readability.SpaceAfterCommas, []}, 49 | {Credo.Check.Readability.StringSigils, []}, 50 | {Credo.Check.Readability.TrailingBlankLine, []}, 51 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 52 | {Credo.Check.Readability.VariableNames, []}, 53 | 54 | # Refactoring Opportunities 55 | 56 | {Credo.Check.Refactor.CondStatements, []}, 57 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 58 | {Credo.Check.Refactor.FunctionArity, []}, 59 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 60 | {Credo.Check.Refactor.MapInto, false}, 61 | {Credo.Check.Refactor.MatchInCondition, []}, 62 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 63 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 64 | {Credo.Check.Refactor.Nesting, []}, 65 | {Credo.Check.Refactor.PipeChainStart, 66 | [ 67 | excluded_argument_types: [:atom, :binary, :fn, :keyword, :number], 68 | excluded_functions: [] 69 | ]}, 70 | {Credo.Check.Refactor.UnlessWithElse, []}, 71 | 72 | # Warnings 73 | 74 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 75 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 76 | {Credo.Check.Warning.IExPry, []}, 77 | {Credo.Check.Warning.IoInspect, []}, 78 | {Credo.Check.Warning.LazyLogging, false}, 79 | {Credo.Check.Warning.OperationOnSameValues, []}, 80 | {Credo.Check.Warning.OperationWithConstantResult, []}, 81 | {Credo.Check.Warning.RaiseInsideRescue, []}, 82 | {Credo.Check.Warning.UnusedEnumOperation, []}, 83 | {Credo.Check.Warning.UnusedFileOperation, []}, 84 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 85 | {Credo.Check.Warning.UnusedListOperation, []}, 86 | {Credo.Check.Warning.UnusedPathOperation, []}, 87 | {Credo.Check.Warning.UnusedRegexOperation, []}, 88 | {Credo.Check.Warning.UnusedStringOperation, []}, 89 | {Credo.Check.Warning.UnusedTupleOperation, []}, 90 | 91 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 92 | 93 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 94 | {Credo.Check.Design.DuplicatedCode, false}, 95 | {Credo.Check.Readability.Specs, []}, 96 | {Credo.Check.Refactor.ABCSize, false}, 97 | {Credo.Check.Refactor.AppendSingleItem, false}, 98 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 99 | {Credo.Check.Refactor.ModuleDependencies, false}, 100 | {Credo.Check.Refactor.VariableRebinding, false}, 101 | {Credo.Check.Warning.MapGetUnsafePass, false}, 102 | {Credo.Check.Warning.UnsafeToAtom, false} 103 | ] 104 | } 105 | ] 106 | } 107 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | ~r/unknown_function.*__impl__.*/ 3 | ] 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{bench,config,lib,test}/**/*.{ex,exs}"], 3 | locals_without_parens: [cover: 1] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Describe a way in which xgit doesn't behave as expected 4 | --- 5 | - [ ] Add label: "bug" 6 | - [ ] Add label: "good first issue" or "help wanted" if appropriate 7 | 8 | **Describe the Bug** 9 | _Please provide a clear and concise description of the bug._ 10 | 11 | **Steps to Reproduce** 12 | _How do you demonstrate the bug? A PR with a failing unit test is the ideal report._ 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Describe something new you'd like xgit to do 4 | --- 5 | - [ ] Add label: "enhancement" 6 | - [ ] Add label: "good first issue" or "help wanted" if appropriate 7 | 8 | **Describe the Request** 9 | _Please provide a clear and concise description of the thing you're hoping xgit will do._ 10 | 11 | **Definition of Done** 12 | _How do you define "done" for this feature? A PR with a failing unit test is the ideal report._ 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/to-do.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: TO DO 3 | about: Describe an existing aspect of xgit which has not been completely implemented. 4 | --- 5 | - [ ] Add label: "TO DO" 6 | - [ ] Add label: "good first issue" or "help wanted" if appropriate 7 | - [ ] Add link to this issue in a comment near the `TO DO` comment 8 | 9 | **Describe What Is Incomplete** 10 | _Please provide a clear and concise description of the existing feature that xgit doesn't completely fulfill._ 11 | 12 | **Definition of Done** 13 | _How do you define "done" for this feature? A PR with a failing unit test is the ideal report._ 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Changes in This Pull Request 2 | _Give a narrative description of what has been changed._ 3 | 4 | ## Checklist 5 | - [ ] This PR represents a single feature, fix, or change. 6 | - [ ] All applicable changes have been documented. 7 | - [ ] There is test coverage for all changes. 8 | - [ ] All cases where a literal value is returned use the `cover` macro to force code coverage. 9 | - [ ] Any code ported from jgit maintains all existing copyright and license notices. 10 | - [ ] If new files are ported from jgit, the path to the corresponding file(s) is included in the header comment. 11 | - [ ] Any `TO DO` items (or similar) have been entered as GitHub issues and the link to that issue has been included in a comment. 12 | -------------------------------------------------------------------------------- /.github/workflows/credo-format.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | credo-format: 5 | runs-on: ubuntu-latest 6 | name: Code format and Credo 7 | strategy: 8 | matrix: 9 | otp: [22.2] 10 | elixir: [1.10.0] 11 | env: 12 | MIX_ENV: test 13 | steps: 14 | - uses: actions/checkout@v1.0.0 15 | - uses: actions/setup-elixir@v1.0.0 16 | with: 17 | otp-version: ${{matrix.otp}} 18 | elixir-version: ${{matrix.elixir}} 19 | - uses: actions/cache@v1 20 | id: cache-mix-deps 21 | with: 22 | path: deps 23 | key: ${{matrix.otp}}-${{matrix.elixir}}-mix-${{hashFiles(format('{0}{1}', github.workspace, '/mix.exs'))}}-${{hashFiles(format('{0}{1}', github.workspace, '/mix.lock'))}} 24 | restore-keys: | 25 | ${{matrix.otp}}-${{matrix.elixir}}-mix- 26 | - run: mix format --check-formatted 27 | - run: mix deps.get 28 | if: steps.cache-mix-deps.outputs.cache-hit != 'true' 29 | - run: mix deps.compile 30 | if: steps.cache-mix-deps.outputs.cache-hit != 'true' 31 | - run: mix credo 32 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | test-coverage: 5 | runs-on: ubuntu-latest 6 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 7 | strategy: 8 | matrix: 9 | otp: [21.3.8.10, 22.2] 10 | elixir: [1.8.2, 1.9.4, 1.10.0] 11 | exclude: 12 | - {otp: "21.3.8.10", elixir: "1.10.0"} 13 | 14 | env: 15 | MIX_ENV: test 16 | steps: 17 | - uses: actions/checkout@v1.0.0 18 | - uses: actions/setup-elixir@v1.0.0 19 | with: 20 | otp-version: ${{matrix.otp}} 21 | elixir-version: ${{matrix.elixir}} 22 | - uses: actions/cache@v1 23 | id: cache-mix-deps 24 | with: 25 | path: deps 26 | key: ${{matrix.otp}}-${{matrix.elixir}}-mix-${{hashFiles(format('{0}{1}', github.workspace, '/mix.exs'))}}-${{hashFiles(format('{0}{1}', github.workspace, '/mix.lock'))}} 27 | restore-keys: | 28 | ${{matrix.otp}}-${{matrix.elixir}}-mix- 29 | - run: mix deps.get 30 | if: steps.cache-mix-deps.outputs.cache-hit != 'true' 31 | - run: mix deps.compile 32 | if: steps.cache-mix-deps.outputs.cache-hit != 'true' 33 | - run: mix coveralls.json 34 | - name: Upload coverage to CodeCov 35 | uses: codecov/codecov-action@v1.0.3 36 | with: 37 | token: ${{secrets.CODECOV_TOKEN}} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /.fetch 6 | erl_crash.dump 7 | *.ez 8 | *.beam 9 | /config/*.secret.exs 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PROJECT PAUSED 2 | 3 | My personal and professional interests have changed and, much as I appreciate the language and the community, I'm no longer writing code in Elixir. 4 | 5 | If you are interested in taking over this project, please [raise an issue](https://github.com/elixir-git/xgit/issues/new/choose) to let me know and I will work with you to transfer ownership of the project. 6 | 7 | --- 8 | 9 | # Xgit Xgit 10 | 11 | Pure Elixir native implementation of git 12 | 13 | ![Build Status](https://github.com/elixir-git/xgit/workflows/.github/workflows/test-coverage.yml/badge.svg?branch=master) 14 | [![Code Coverage](https://codecov.io/gh/elixir-git/xgit/branch/master/graph/badge.svg)](https://codecov.io/gh/elixir-git/xgit) 15 | [![Hex version](https://img.shields.io/hexpm/v/xgit.svg)](https://hex.pm/packages/xgit) 16 | [![API Docs](https://img.shields.io/badge/hexdocs-release-blue.svg)](https://hexdocs.pm/xgit) 17 | [![License badge](https://img.shields.io/hexpm/l/xgit.svg)](https://github.com/elixir-git/xgit/blob/master/LICENSE) 18 | 19 | --- 20 | 21 | ## WORK IN PROGRESS 22 | 23 | **This is very much a work in progress and not ready to be used in production.** What is implemented is well-tested and believed to be correct and stable, but much of the core git infrastructure is not yet implemented. There has been little attention, as yet, to measuring performance. 24 | 25 | **For information about the progress of this project,** please see the [**Xgit Reflog** (blog)](https://xgit.io). 26 | 27 | 28 | ## Where Can I Help? 29 | 30 | This version of Xgit replaces an earlier version which was a port from the [Java implementation of git, jgit](https://www.eclipse.org/jgit/). In coming days/weeks, I'll share more about the new direction and where help would be most welcome. 31 | 32 | For now, please see: 33 | 34 | * [Issues tagged "good first issue"](https://github.com/elixir-git/xgit/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) 35 | * [Issues tagged "help wanted"](https://github.com/elixir-git/xgit/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) _more issues, but potentially more challenging_ 36 | 37 | 38 | ## Why an All-Elixir Implementation? 39 | 40 | With all of git already implemented in [libgit2](https://github.com/libgit2/libgit2), why do it again? 41 | 42 | I considered that, and then I read [Andrea Leopardi](https://andrealeopardi.com/posts/using-c-from-elixir-with-nifs/): 43 | 44 | > **NIFs are dangerous.** I bet you’ve heard about how Erlang (and Elixir) are reliable and fault-tolerant, how processes are isolated and a crash in a process only takes that process down, and other resiliency properties. You can kiss all that good stuff goodbye when you start to play with NIFs. A crash in a NIF (such as a dreaded segmentation fault) will **crash the entire Erlang VM.** No supervisors to the rescue, no fault-tolerance, no isolation. This means you need to be extremely careful when writing NIFs, and you should always make sure that you have a good reason to use them. 45 | 46 | libgit2 is a big, complex library. And while it's been battle-tested, it's also a large C library, which means it takes on the risks cited above, will interfere with the Erlang VM scheduler, and make the build process far more complicated. I also hope to make it easy to make portions of the back-end (notably, storage) configurable; that will be far easier with an all-Elixir implementation. 47 | 48 | 49 | ## Credits 50 | 51 | Xgit is heavily influenced by [jgit](https://www.eclipse.org/jgit/), an all-Java implementation of git. Many thanks to the jgit team for their hard work. Small portions of Xgit are based on [an earlier port from Java to Elixir](https://github.com/elixir-git/archived-jgit-port/); those files retain the original credits and license from the jgit project. 52 | -------------------------------------------------------------------------------- /bench/README.md: -------------------------------------------------------------------------------- 1 | When there's a concern about algorithmic or absolute cost of a specific implementation, 2 | let's build a microbenchmark here. As of this writing (mid-September 2019), I think the 3 | following functions need study. 4 | 5 | ``` 6 | Xgit.DirCache 7 | add_entries/2 8 | fully_merged?/1 9 | remove_entries/2 10 | to_tree_objects/2 11 | to_iodevice/1 12 | 13 | Xgit.FilePath 14 | check_path_segment/2 15 | check_path/2 16 | valid?/2 17 | 18 | Xgit.Object 19 | valid?/1 20 | 21 | Xgit.Repository.Storage (permute on the implementations) 22 | get_object/2 23 | has_all_object_ids?/2 24 | put_loose_object/2 25 | 26 | Xgit.Tree 27 | from_object/1 28 | to_object/1 29 | ``` 30 | -------------------------------------------------------------------------------- /bench/xgit/repository/working_tree/parse_index_file/from_iodevice.exs: -------------------------------------------------------------------------------- 1 | # Measure cost of Xgit.DirCache.from_iodevice/1. 2 | # 3 | # EXPECTED: Cost is roughly O(n) on the number of items in the index file. 4 | # 5 | # -------------------------------------------------------------------------------------- 6 | # 7 | # $ mix run bench/xgit/repository/working_tree/parse_index_file/from_iodevice.exs 8 | # Operating System: macOS 9 | # CPU Information: Intel(R) Core(TM) i7-4980HQ CPU @ 2.80GHz 10 | # Number of Available Cores: 8 11 | # Available memory: 16 GB 12 | # Elixir 1.9.1 13 | # Erlang 22.0.7 14 | # 15 | # Benchmark suite executing with the following configuration: 16 | # warmup: 2 s 17 | # time: 5 s 18 | # memory time: 0 ns 19 | # parallel: 1 20 | # inputs: 10 items, 100 items, 1000 items 21 | # Estimated total run time: 21 s 22 | # 23 | # Benchmarking parse_index_file with input 10 items... 24 | # Benchmarking parse_index_file with input 100 items... 25 | # Benchmarking parse_index_file with input 1000 items... 26 | # 27 | # ##### With input 10 items ##### 28 | # Name ips average deviation median 99th % 29 | # parse_index_file 1.14 K 880.11 μs ±7.61% 871 μs 1090.70 μs 30 | # 31 | # ##### With input 100 items ##### 32 | # Name ips average deviation median 99th % 33 | # parse_index_file 146.25 6.84 ms ±3.99% 6.81 ms 7.69 ms 34 | # 35 | # ##### With input 1000 items ##### 36 | # Name ips average deviation median 99th % 37 | # parse_index_file 14.87 67.23 ms ±1.97% 67.01 ms 73.24 ms 38 | # 39 | # -------------------------------------------------------------------------------------- 40 | 41 | alias Xgit.DirCache 42 | alias Xgit.Util.TrailingHashDevice 43 | 44 | Temp.track!() 45 | 46 | make_index_file_with_n_entries = fn n -> 47 | git_dir = Temp.mkdir!() 48 | 49 | {_output, 0} = System.cmd("git", ["init"], cd: git_dir) 50 | 51 | Enum.map(1..n, fn i -> 52 | name = "0000#{i}" 53 | 54 | {_output, _0} = 55 | System.cmd( 56 | "git", 57 | [ 58 | "update-index", 59 | "--add", 60 | "--cacheinfo", 61 | "100644", 62 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 63 | "a#{String.slice(name, -4, 4)}" 64 | ], 65 | cd: git_dir 66 | ) 67 | end) 68 | 69 | Path.join([git_dir, ".git", "index"]) 70 | end 71 | 72 | thd_open_file! = fn path -> 73 | {:ok, iodevice} = TrailingHashDevice.open_file(path) 74 | iodevice 75 | end 76 | 77 | inputs = %{ 78 | "10 items" => make_index_file_with_n_entries.(10), 79 | "100 items" => make_index_file_with_n_entries.(100), 80 | "1000 items" => make_index_file_with_n_entries.(1000) 81 | } 82 | 83 | Benchee.run( 84 | %{ 85 | "parse_index_file" => fn index_file_path -> 86 | iodevice = thd_open_file!.(index_file_path) 87 | DirCache.from_iodevice(iodevice) 88 | File.close(iodevice) 89 | end 90 | }, 91 | inputs: inputs 92 | ) 93 | -------------------------------------------------------------------------------- /branding/xgit-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-git/xgit/0e2f849c83cdf39a9249b319d63ff3682c482c2f/branding/xgit-logo.png -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :xgit, use_force_coverage?: true 4 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/xgit.ex", 4 | "test/support" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /lib/xgit.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit do 2 | @moduledoc """ 3 | Just a tiny little project. 4 | """ 5 | use Application 6 | 7 | @doc """ 8 | Start Xgit application. 9 | """ 10 | @impl true 11 | def start(_type, _args) do 12 | Supervisor.start_link([], strategy: :one_for_one) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/xgit/README.md: -------------------------------------------------------------------------------- 1 | # Code Organization 2 | 3 | ## Design Goal 4 | 5 | A primary design goal of Xgit is to allow git repositories to be stored in 6 | arbitrary locations other than file systems. (In a server environment, it likely 7 | makes sense to store content in a database or cloud-based file system such as S3.) 8 | 9 | For that reason, the concept of **"repository"** in Xgit is kept intentionally 10 | minimal. `Xgit.Repository.Storage` defines a behaviour module that describes the interface 11 | that a storage implementor would need to implement and very little else. (A repository 12 | is implemented using `GenServer` so that it can maintain its state independently. 13 | `Xgit.Repository.Storage` provides a wrapper interface for the calls that other modules 14 | within Xgit need to make to manipulate the repository.) 15 | 16 | A **typical end-user developer** will typically construct an instance of `Xgit.Repository.OnDisk` 17 | (or some other module that implements a different storage architecture as described 18 | next) and then use the functions in `Xgit.Repository` to inspect and modify the repository. 19 | (These modules are agnostic with regard to storage architecture to the maximum 20 | extent possible.) 21 | 22 | A **storage architect** will construct a module that encapsulates the desired storage mechanism 23 | in a `GenServer` process and makes that available to the rest of Xgit by implementing 24 | the `Xgit.Repository.Storage` behaviour interface. 25 | 26 | **Guideline:** With the exception of the reference implementation `Xgit.Repository.OnDisk`, 27 | all code in Xgit should be implemented without knowledge of how and where content is stored. 28 | -------------------------------------------------------------------------------- /lib/xgit/commit.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Commit do 2 | @moduledoc ~S""" 3 | Represents a git `commit` object in memory. 4 | """ 5 | alias Xgit.ContentSource 6 | alias Xgit.Object 7 | alias Xgit.ObjectId 8 | alias Xgit.PersonIdent 9 | 10 | import Xgit.Util.ForceCoverage 11 | import Xgit.Util.ParseHeader, only: [next_header: 1] 12 | 13 | @typedoc ~S""" 14 | This struct describes a single `commit` object so it can be manipulated in memory. 15 | 16 | ## Struct Members 17 | 18 | * `:tree`: (`Xgit.ObjectId`) tree referenced by this commit 19 | * `:parents`: (list of `Xgit.ObjectId`) parent(s) of this commit 20 | * `:author`: (`Xgit.PersonIdent`) author of this commit 21 | * `:committer`: (`Xgit.PersonIdent`) committer for this commit 22 | * `:message`: (bytelist) user-entered commit message (encoding unspecified) 23 | 24 | **TO DO:** Support signatures and other extensions. 25 | https://github.com/elixir-git/xgit/issues/202 26 | """ 27 | @type t :: %__MODULE__{ 28 | tree: ObjectId.t(), 29 | parents: [ObjectId.t()], 30 | author: PersonIdent.t(), 31 | committer: PersonIdent.t(), 32 | message: [byte] 33 | } 34 | 35 | @enforce_keys [:tree, :author, :committer, :message] 36 | defstruct [:tree, :author, :committer, :message, parents: []] 37 | 38 | @doc ~S""" 39 | Return `true` if the value is a commit struct that is valid. 40 | """ 41 | @spec valid?(commit :: any) :: boolean 42 | def valid?(commit) 43 | 44 | def valid?(%__MODULE__{ 45 | tree: tree, 46 | parents: parents, 47 | author: %PersonIdent{} = author, 48 | committer: %PersonIdent{} = committer, 49 | message: message 50 | }) 51 | when is_binary(tree) and is_list(parents) and is_list(message) do 52 | ObjectId.valid?(tree) && 53 | Enum.all?(parents, &ObjectId.valid?(&1)) && 54 | PersonIdent.valid?(author) && 55 | PersonIdent.valid?(committer) && 56 | not Enum.empty?(message) 57 | end 58 | 59 | def valid?(_), do: cover(false) 60 | 61 | @typedoc ~S""" 62 | Error response codes returned by `from_object/1`. 63 | """ 64 | @type from_object_reason :: :not_a_commit | :invalid_commit 65 | 66 | @doc ~S""" 67 | Renders a commit structure from an `Xgit.Object`. 68 | 69 | ## Return Values 70 | 71 | `{:ok, commit}` if the object contains a valid `commit` object. 72 | 73 | `{:error, :not_a_commit}` if the object contains an object of a different type. 74 | 75 | `{:error, :invalid_commit}` if the object says that is of type `commit`, but 76 | can not be parsed as such. 77 | """ 78 | @spec from_object(object :: Object.t()) :: {:ok, commit :: t} | {:error, from_object_reason} 79 | def from_object(object) 80 | 81 | def from_object(%Object{type: :commit, content: content} = _object) do 82 | content 83 | |> ContentSource.stream() 84 | |> Enum.to_list() 85 | |> from_object_internal() 86 | end 87 | 88 | def from_object(%Object{} = _object), do: cover({:error, :not_a_commit}) 89 | 90 | defp from_object_internal(data) do 91 | with {:tree, {'tree', tree_id_str, data}} <- {:tree, next_header(data)}, 92 | {:tree_id, {tree_id, []}} <- {:tree_id, ObjectId.from_hex_charlist(tree_id_str)}, 93 | {:parents, {parents, data}} when is_list(data) <- 94 | {:parents, read_parents(data, [])}, 95 | {:author, {'author', author_str, data}} <- {:author, next_header(data)}, 96 | {:author_id, %PersonIdent{} = author} <- 97 | {:author_id, PersonIdent.from_byte_list(author_str)}, 98 | {:committer, {'committer', committer_str, data}} <- 99 | {:committer, next_header(data)}, 100 | {:committer_id, %PersonIdent{} = committer} <- 101 | {:committer_id, PersonIdent.from_byte_list(committer_str)}, 102 | message when is_list(message) <- drop_if_lf(data) do 103 | # TO DO: Support signatures and other extensions. 104 | # https://github.com/elixir-git/xgit/issues/202 105 | cover {:ok, 106 | %__MODULE__{ 107 | tree: tree_id, 108 | parents: parents, 109 | author: author, 110 | committer: committer, 111 | message: message 112 | }} 113 | else 114 | _ -> cover {:error, :invalid_commit} 115 | end 116 | end 117 | 118 | defp read_parents(data, parents_acc) do 119 | with {'parent', parent_id, next_data} <- next_header(data), 120 | {:parent_id, {parent_id, []}} <- {:parent_id, ObjectId.from_hex_charlist(parent_id)} do 121 | read_parents(next_data, [parent_id | parents_acc]) 122 | else 123 | {:parent_id, _} -> cover :error 124 | _ -> cover {Enum.reverse(parents_acc), data} 125 | end 126 | end 127 | 128 | defp drop_if_lf([10 | data]), do: cover(data) 129 | defp drop_if_lf([]), do: cover([]) 130 | defp drop_if_lf(_), do: cover(:error) 131 | 132 | @doc ~S""" 133 | Renders this commit structure into a corresponding `Xgit.Object`. 134 | 135 | If duplicate parents are detected, they will be silently de-duplicated. 136 | 137 | If the commit structure is not valid, will raise `ArgumentError`. 138 | """ 139 | @spec to_object(commit :: t) :: Object.t() 140 | def to_object(commit) 141 | 142 | def to_object( 143 | %__MODULE__{ 144 | tree: tree, 145 | parents: parents, 146 | author: %PersonIdent{} = author, 147 | committer: %PersonIdent{} = committer, 148 | message: message 149 | } = commit 150 | ) do 151 | unless valid?(commit) do 152 | raise ArgumentError, "Xgit.Commit.to_object/1: commit is not valid" 153 | end 154 | 155 | rendered_parents = 156 | parents 157 | |> Enum.uniq() 158 | |> Enum.flat_map(&'parent #{&1}\n') 159 | 160 | rendered_commit = 161 | 'tree #{tree}\n' ++ 162 | rendered_parents ++ 163 | 'author #{PersonIdent.to_external_string(author)}\n' ++ 164 | 'committer #{PersonIdent.to_external_string(committer)}\n' ++ 165 | '\n' ++ 166 | message 167 | 168 | # TO DO: Support signatures and other extensions. 169 | # https://github.com/elixir-git/xgit/issues/202 170 | 171 | %Object{ 172 | type: :commit, 173 | content: rendered_commit, 174 | size: Enum.count(rendered_commit), 175 | id: ObjectId.calculate_id(rendered_commit, :commit) 176 | } 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/xgit/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Config do 2 | @moduledoc ~S""" 3 | Provides convenience functions to get specific configuration values from a repository. 4 | 5 | IMPORTANT: The on-disk repository implementation (`Xgit.Repository.OnDisk`) does not 6 | examine configuration directives in global or home directory. 7 | 8 | This module should be considered roughly analogous to the 9 | [`git config`](https://git-scm.com/docs/git-config) command. 10 | """ 11 | 12 | alias Xgit.Repository 13 | alias Xgit.Repository.Storage 14 | 15 | import Xgit.Util.ForceCoverage 16 | 17 | @doc ~S""" 18 | Get the list of strings for this variable. 19 | """ 20 | @spec get_string_list( 21 | repository :: Repository.t(), 22 | section :: String.t(), 23 | subsection :: String.t() | nil, 24 | name :: String.t() 25 | ) :: [String.t() | nil] 26 | def get_string_list(repository, section, subsection \\ nil, name) 27 | 28 | def get_string_list(repository, section, nil, name) 29 | when is_binary(section) and is_binary(name) do 30 | repository 31 | |> Storage.get_config_entries(section: section, name: name) 32 | |> entries_to_values() 33 | end 34 | 35 | def get_string_list(repository, section, subsection, name) 36 | when is_binary(section) and is_binary(subsection) and is_binary(name) do 37 | repository 38 | |> Storage.get_config_entries(section: section, subsection: subsection, name: name) 39 | |> entries_to_values() 40 | end 41 | 42 | defp entries_to_values(config_entries) do 43 | Enum.map(config_entries, fn %{value: value} -> 44 | cover value 45 | end) 46 | end 47 | 48 | @doc ~S""" 49 | If there is a single string for this variable, return it. 50 | 51 | If there are zero or multiple values for this variable, return `nil`. 52 | 53 | If there is exactly one value, but it was implied (missing `=`), return `:empty`. 54 | """ 55 | @spec get_string( 56 | repository :: Repository.t(), 57 | section :: String.t(), 58 | subsection :: String.t() | nil, 59 | name :: String.t() 60 | ) :: String.t() | nil | :empty 61 | def get_string(repository, section, subsection \\ nil, name) do 62 | repository 63 | |> get_string_list(section, subsection, name) 64 | |> single_string_value() 65 | end 66 | 67 | defp single_string_value([value]) when is_binary(value) do 68 | cover value 69 | end 70 | 71 | defp single_string_value([nil]) do 72 | cover :empty 73 | end 74 | 75 | defp single_string_value(_) do 76 | cover nil 77 | end 78 | 79 | @doc ~S""" 80 | Return the config value interpreted as an integer. 81 | 82 | Use `default` if it can not be interpreted as such. 83 | """ 84 | @spec get_integer( 85 | repository :: Repository.t(), 86 | section :: String.t(), 87 | subsection :: String.t() | nil, 88 | name :: String.t(), 89 | default :: integer() 90 | ) :: integer() 91 | def get_integer(repository, section, subsection \\ nil, name, default) 92 | when is_integer(default) do 93 | repository 94 | |> get_string(section, subsection, name) 95 | |> to_integer_or_default(default) 96 | end 97 | 98 | defp to_integer_or_default(nil, default), do: cover(default) 99 | defp to_integer_or_default(:empty, default), do: cover(default) 100 | 101 | defp to_integer_or_default(value, default) do 102 | case Integer.parse(value) do 103 | {n, ""} -> cover n 104 | {n, "k"} -> cover n * 1024 105 | {n, "K"} -> cover n * 1024 106 | {n, "m"} -> cover n * 1024 * 1024 107 | {n, "M"} -> cover n * 1024 * 1024 108 | {n, "g"} -> cover n * 1024 * 1024 * 1024 109 | {n, "G"} -> cover n * 1024 * 1024 * 1024 110 | _ -> cover default 111 | end 112 | end 113 | 114 | @doc ~S""" 115 | Return the config value interpreted as a boolean. 116 | 117 | Use `default` if it can not be interpreted as such. 118 | """ 119 | @spec get_boolean( 120 | repository :: Repository.t(), 121 | section :: String.t(), 122 | subsection :: String.t() | nil, 123 | name :: String.t(), 124 | default :: boolean() 125 | ) :: boolean() 126 | def get_boolean(repository, section, subsection \\ nil, name, default) 127 | when is_boolean(default) do 128 | repository 129 | |> get_string(section, subsection, name) 130 | |> to_lower_if_string() 131 | |> to_boolean_or_default(default) 132 | end 133 | 134 | defp to_lower_if_string(nil), do: cover(nil) 135 | defp to_lower_if_string(:empty), do: cover(:empty) 136 | defp to_lower_if_string(s) when is_binary(s), do: String.downcase(s) 137 | 138 | defp to_boolean_or_default("yes", _default), do: cover(true) 139 | defp to_boolean_or_default("on", _default), do: cover(true) 140 | defp to_boolean_or_default("true", _default), do: cover(true) 141 | defp to_boolean_or_default("1", _default), do: cover(true) 142 | defp to_boolean_or_default(:empty, _default), do: cover(true) 143 | 144 | defp to_boolean_or_default("no", _default), do: cover(false) 145 | defp to_boolean_or_default("off", _default), do: cover(false) 146 | defp to_boolean_or_default("false", _default), do: cover(false) 147 | defp to_boolean_or_default("0", _default), do: cover(false) 148 | defp to_boolean_or_default("", _default), do: cover(false) 149 | 150 | defp to_boolean_or_default(_, default), do: cover(default) 151 | end 152 | -------------------------------------------------------------------------------- /lib/xgit/config_entry.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.ConfigEntry do 2 | @moduledoc ~S""" 3 | Represents one entry in a git configuration dictionary. 4 | 5 | This is also commonly referred to as a "config _line_" because it typically 6 | occupies one line in a typical git configuration file. 7 | 8 | The semantically-important portion of a configuration file (i.e. everything 9 | except comments and whitespace) could be represented by a list of `ConfigEntry` 10 | structs. 11 | """ 12 | 13 | import Xgit.Util.ForceCoverage 14 | 15 | @typedoc ~S""" 16 | Represents an entry in a git config file. 17 | 18 | ## Struct Members 19 | 20 | * `section`: (`String`) section name for the entry 21 | * `subsection`: (`String` or `nil`) subsection name 22 | * `name`: (`String`) key name 23 | * `value`: (`String`, `nil`, or `:remove_all`) value 24 | * `nil` if the name is present without an `=` 25 | * `:remove_all` can be used as an instruction in some APIs to remove any corresponding entries 26 | """ 27 | @type t :: %__MODULE__{ 28 | section: String.t(), 29 | subsection: String.t() | nil, 30 | name: String.t(), 31 | value: String.t() | :remove_all | nil 32 | } 33 | 34 | @enforce_keys [:section, :subsection, :name, :value] 35 | defstruct [:section, :name, subsection: nil, value: nil] 36 | 37 | @doc ~S""" 38 | Returns `true` if passed a valid config entry. 39 | """ 40 | @spec valid?(value :: any) :: boolean 41 | def valid?(%__MODULE__{} = entry) do 42 | valid_section?(entry.section) && 43 | valid_subsection?(entry.subsection) && 44 | valid_name?(entry.name) && 45 | valid_value?(entry.value) 46 | end 47 | 48 | def valid?(_), do: cover(false) 49 | 50 | @doc ~S""" 51 | Returns `true` if passed a valid config section name. 52 | 53 | Only alphanumeric characters, `-`, and `.` are allowed in section names. 54 | """ 55 | @spec valid_section?(section :: any) :: boolean 56 | def valid_section?(section) when is_binary(section) do 57 | String.match?(section, ~r/^[-A-Za-z0-9.]+$/) 58 | end 59 | 60 | def valid_section?(_), do: cover(false) 61 | 62 | @doc ~S""" 63 | Returns `true` if passed a valid config subsection name. 64 | """ 65 | @spec valid_subsection?(subsection :: any) :: boolean 66 | def valid_subsection?(subsection) when is_binary(subsection) do 67 | if String.match?(subsection, ~r/[\0\n]/) do 68 | cover false 69 | else 70 | cover true 71 | end 72 | end 73 | 74 | def valid_subsection?(nil), do: cover(true) 75 | def valid_subsection?(_), do: cover(false) 76 | 77 | @doc ~S""" 78 | Returns `true` if passed a valid config entry name. 79 | """ 80 | @spec valid_name?(name :: any) :: boolean 81 | def valid_name?(name) when is_binary(name) do 82 | String.match?(name, ~r/^[A-Za-z][-A-Za-z0-9]*$/) 83 | end 84 | 85 | def valid_name?(_), do: cover(false) 86 | 87 | @doc ~S""" 88 | Returns `true` if passed a valid config value string. 89 | 90 | Important: At this level, we do not accept other data types. 91 | """ 92 | @spec valid_value?(value :: any) :: boolean 93 | def valid_value?(value) when is_binary(value) do 94 | if String.match?(value, ~r/\0/) do 95 | cover false 96 | else 97 | cover true 98 | end 99 | end 100 | 101 | def valid_value?(nil), do: cover(true) 102 | def valid_value?(:remove_all), do: cover(true) 103 | def valid_value?(_), do: cover(false) 104 | end 105 | -------------------------------------------------------------------------------- /lib/xgit/content_source.ex: -------------------------------------------------------------------------------- 1 | defprotocol Xgit.ContentSource do 2 | @moduledoc ~S""" 3 | Protocol used for reading object content from various sources. 4 | """ 5 | 6 | @typedoc ~S""" 7 | Any value for which `ContentSource` protocol is implemented. 8 | """ 9 | @type t :: term 10 | 11 | @doc ~S""" 12 | Calculate the length (in bytes) of the content. 13 | """ 14 | @spec length(content :: t) :: non_neg_integer 15 | def length(content) 16 | 17 | @doc ~S""" 18 | Return a stream which can be used for reading the content. 19 | """ 20 | @spec stream(content :: t) :: Enumerable.t() 21 | def stream(content) 22 | end 23 | 24 | defimpl Xgit.ContentSource, for: List do 25 | @impl true 26 | def length(list), do: Enum.count(list) 27 | 28 | @impl true 29 | def stream(list), do: list 30 | end 31 | 32 | defimpl Xgit.ContentSource, for: BitString do 33 | @impl true 34 | def length(s), do: byte_size(s) 35 | 36 | @impl true 37 | def stream(s), do: :binary.bin_to_list(s) 38 | end 39 | -------------------------------------------------------------------------------- /lib/xgit/file_content_source.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.FileContentSource do 2 | @moduledoc ~S""" 3 | Implements `Xgit.ContentSource` to read content from a file on disk. 4 | """ 5 | 6 | import Xgit.Util.ForceCoverage 7 | 8 | @typedoc ~S""" 9 | Describes a file on disk which will be used for reading content. 10 | """ 11 | @type t :: %__MODULE__{ 12 | path: Path.t(), 13 | size: non_neg_integer | :file_not_found 14 | } 15 | 16 | @enforce_keys [:path, :size] 17 | defstruct [:path, :size] 18 | 19 | @doc ~S""" 20 | Creates an `Xgit.FileContentSource` for a file on disk. 21 | """ 22 | @spec new(path :: Path.t()) :: t 23 | def new(path) when is_binary(path) do 24 | size = 25 | case File.stat(path) do 26 | {:ok, %File.Stat{size: size}} -> cover size 27 | _ -> cover :file_not_found 28 | end 29 | 30 | %__MODULE__{path: path, size: size} 31 | end 32 | 33 | defimpl Xgit.ContentSource do 34 | alias Xgit.FileContentSource, as: FCS 35 | @impl true 36 | def length(%FCS{size: :file_not_found}), do: raise("file not found") 37 | def length(%FCS{size: size}), do: cover(size) 38 | 39 | @impl true 40 | def stream(%FCS{size: :file_not_found}), do: raise("file not found") 41 | def stream(%FCS{path: path}), do: File.stream!(path, [:charlist], 2048) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/xgit/file_mode.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.FileMode do 2 | @moduledoc ~S""" 3 | Describes the file type as represented on disk. 4 | """ 5 | 6 | import Xgit.Util.ForceCoverage 7 | 8 | @typedoc ~S""" 9 | An integer describing the file type as represented on disk. 10 | 11 | Git uses a variation on the Unix file permissions flags to denote a file's 12 | intended type on disk. The following values are recognized: 13 | 14 | * `0o100644` - normal file 15 | * `0o100755` - executable file 16 | * `0o120000` - symbolic link 17 | * `0o040000` - tree (subdirectory) 18 | * `0o160000` - submodule (aka gitlink) 19 | 20 | This module is intended to be `use`d. Doing so will create an `alias` to the module 21 | so as to make `FileMode.t` available for typespecs and will `import` the 22 | `is_file_mode/1` guard. 23 | """ 24 | @type t :: 0o100644 | 0o100755 | 0o120000 | 0o040000 | 0o160000 25 | 26 | @doc "Mode indicating an entry is a tree (aka directory)." 27 | @spec tree :: t 28 | def tree, do: cover(0o040000) 29 | 30 | @doc "Mode indicating an entry is a symbolic link." 31 | @spec symlink :: t 32 | def symlink, do: cover(0o120000) 33 | 34 | @doc "Mode indicating an entry is a non-executable file." 35 | @spec regular_file :: t 36 | def regular_file, do: cover(0o100644) 37 | 38 | @doc "Mode indicating an entry is an executable file." 39 | @spec executable_file :: t 40 | def executable_file, do: cover(0o100755) 41 | 42 | @doc "Mode indicating an entry is a submodule commit in another repository." 43 | @spec gitlink :: t 44 | def gitlink, do: cover(0o160000) 45 | 46 | @doc "Return `true` if the file mode represents a tree." 47 | @spec tree?(file_mode :: term) :: boolean 48 | def tree?(file_mode) 49 | def tree?(0o040000), do: cover(true) 50 | def tree?(_), do: cover(false) 51 | 52 | @doc "Return `true` if the file mode a symbolic link." 53 | @spec symlink?(file_mode :: term) :: boolean 54 | def symlink?(file_mode) 55 | def symlink?(0o120000), do: cover(true) 56 | def symlink?(_), do: cover(false) 57 | 58 | @doc "Return `true` if the file mode represents a regular file." 59 | @spec regular_file?(file_mode :: term) :: boolean 60 | def regular_file?(file_mode) 61 | def regular_file?(0o100644), do: cover(true) 62 | def regular_file?(_), do: cover(false) 63 | 64 | @doc "Return `true` if the file mode represents an executable file." 65 | @spec executable_file?(file_mode :: term) :: boolean 66 | def executable_file?(file_mode) 67 | def executable_file?(0o100755), do: cover(true) 68 | def executable_file?(_), do: cover(false) 69 | 70 | @doc "Return `true` if the file mode represents a submodule commit in another repository." 71 | @spec gitlink?(file_mode :: term) :: boolean 72 | def gitlink?(file_mode) 73 | def gitlink?(0o160000), do: cover(true) 74 | def gitlink?(_), do: cover(false) 75 | 76 | @doc ~S""" 77 | Return `true` if the value is one of the known file mode values. 78 | """ 79 | @spec valid?(term) :: boolean 80 | def valid?(0o040000), do: cover(true) 81 | def valid?(0o120000), do: cover(true) 82 | def valid?(0o100644), do: cover(true) 83 | def valid?(0o100755), do: cover(true) 84 | def valid?(0o160000), do: cover(true) 85 | def valid?(_), do: cover(false) 86 | 87 | @valid_file_modes [0o100644, 0o100755, 0o120000, 0o040000, 0o160000] 88 | 89 | @doc ~S""" 90 | Return a rendered version of this file mode as an octal charlist. 91 | 92 | No leading zeros are included. 93 | 94 | Optimized for the known file modes. Errors out for any other mode. 95 | """ 96 | @spec to_short_octal(file_mode :: t) :: charlist 97 | def to_short_octal(file_mode) 98 | 99 | def to_short_octal(0o040000), do: cover('40000') 100 | def to_short_octal(0o120000), do: cover('120000') 101 | def to_short_octal(0o100644), do: cover('100644') 102 | def to_short_octal(0o100755), do: cover('100755') 103 | def to_short_octal(0o160000), do: cover('160000') 104 | 105 | @doc ~S""" 106 | This guard requires the value to be one of the known git file mode values. 107 | """ 108 | defguard is_file_mode(t) when t in @valid_file_modes 109 | 110 | defmacro __using__(opts) do 111 | quote location: :keep, bind_quoted: [opts: opts] do 112 | alias Xgit.FileMode 113 | import Xgit.FileMode, only: [is_file_mode: 1] 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/xgit/object_id.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.ObjectId do 2 | @moduledoc ~S""" 3 | An object ID is a string that identifies an object within a repository. 4 | 5 | This string must match the format for a SHA-1 hash (i.e. 40 characters 6 | of lowercase hex). 7 | """ 8 | use Xgit.ObjectType 9 | 10 | import Xgit.Util.ForceCoverage 11 | 12 | alias Xgit.ContentSource 13 | 14 | @typedoc "A string containing 40 bytes of lowercase hex digits." 15 | @type t :: String.t() 16 | 17 | @doc ~S""" 18 | Get the special all-null object ID, often used to stand-in for no object. 19 | """ 20 | @spec zero :: t 21 | def zero, do: cover("0000000000000000000000000000000000000000") 22 | 23 | @doc ~S""" 24 | Returns `true` if the value is a valid object ID. 25 | 26 | (In other words, is it a string containing 40 characters of lowercase hex?) 27 | """ 28 | @spec valid?(id :: term) :: boolean 29 | def valid?(id) 30 | 31 | def valid?(s) when is_binary(s), do: String.length(s) == 40 && String.match?(s, ~r/^[0-9a-f]+$/) 32 | def valid?(_), do: cover(false) 33 | 34 | @doc ~S""" 35 | Read an object ID from raw binary or bytelist. 36 | 37 | ## Parameters 38 | 39 | `raw_object_id` should be either a binary or list containing a raw object ID (not 40 | hex-encoded). It should be exactly 20 bytes. 41 | 42 | ## Return Value 43 | 44 | The object ID rendered as lowercase hex. (See `Xgit.ObjectId`.) 45 | """ 46 | @spec from_binary_iodata(b :: iodata) :: t 47 | def from_binary_iodata(b) when is_list(b) do 48 | b 49 | |> IO.iodata_to_binary() 50 | |> from_binary_iodata() 51 | end 52 | 53 | def from_binary_iodata(b) when is_binary(b) and byte_size(b) == 20, 54 | do: Base.encode16(b, case: :lower) 55 | 56 | @doc ~S""" 57 | Read an object ID from a hex string (charlist). 58 | 59 | ## Return Value 60 | 61 | If a valid ID is found, returns `{id, next}` where `id` is the matched ID 62 | as a string and `next` is the remainder of the charlist after the matched ID. 63 | 64 | If no such ID is found, returns `false`. 65 | """ 66 | @spec from_hex_charlist(b :: charlist) :: {t, charlist} | false 67 | def from_hex_charlist(b) when is_list(b) do 68 | {maybe_id, remainder} = Enum.split(b, 40) 69 | 70 | with maybe_id_string <- to_string(maybe_id), 71 | true <- valid?(maybe_id_string) do 72 | cover {maybe_id_string, remainder} 73 | else 74 | _ -> cover false 75 | end 76 | end 77 | 78 | @doc ~S""" 79 | Convert an object ID to raw binary representation. 80 | 81 | ## Return Value 82 | 83 | A 20-byte binary encoding the object ID. 84 | """ 85 | @spec to_binary_iodata(id :: t) :: binary 86 | def to_binary_iodata(id), do: Base.decode16!(id, case: :lower) 87 | 88 | @doc ~S""" 89 | Assign an object ID for a given data blob. 90 | 91 | No validation is performed on the content. 92 | 93 | ## Parameters 94 | 95 | * `data` describes how to read the data. (See `Xgit.ContentSource`.) 96 | * `type` is the intended git object type for this data. (See `Xgit.ObjectType`.) 97 | 98 | ## Return Value 99 | 100 | The object ID. (See `Xgit.ObjectId`.) 101 | """ 102 | @spec calculate_id(data :: ContentSource.t(), type :: ObjectType.t()) :: t() 103 | def calculate_id(data, type) when not is_nil(data) and is_object_type(type) do 104 | size = ContentSource.length(data) 105 | 106 | # Erlang/Elixir :sha == SHA-1 107 | :sha 108 | |> :crypto.hash_init() 109 | |> :crypto.hash_update('#{type}') 110 | |> :crypto.hash_update(' ') 111 | |> :crypto.hash_update('#{size}') 112 | |> :crypto.hash_update([0]) 113 | |> hash_update(ContentSource.stream(data)) 114 | |> :crypto.hash_final() 115 | |> from_binary_iodata() 116 | end 117 | 118 | defp hash_update(crypto_state, data) when is_list(data), 119 | do: :crypto.hash_update(crypto_state, data) 120 | 121 | defp hash_update(crypto_state, data) do 122 | Enum.reduce(data, crypto_state, fn item, crypto_state -> 123 | :crypto.hash_update(crypto_state, item) 124 | end) 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/xgit/object_type.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.ObjectType do 2 | @moduledoc ~S""" 3 | Describes the known git object types. 4 | 5 | There are four distinct object types that can be stored in a git repository. 6 | Xgit communicates internally about these object types using the following 7 | atoms: 8 | 9 | * `:blob` 10 | * `:tree` 11 | * `:commit` 12 | * `:tag` 13 | 14 | This module is intended to be `use`d. Doing so will create an `alias` to the module 15 | so as to make `ObjectType.t` available for typespecs and will `import` the 16 | `is_object_type/1` guard. 17 | """ 18 | 19 | import Xgit.Util.ForceCoverage 20 | 21 | @object_types [:blob, :tree, :commit, :tag] 22 | 23 | @typedoc ~S""" 24 | One of the four known git object types, expressed as an atom. 25 | """ 26 | @type t :: :blob | :tree | :commit | :tag 27 | 28 | @doc ~S""" 29 | Return `true` if the value is one of the four known git object types. 30 | """ 31 | @spec valid?(t :: term) :: boolean 32 | def valid?(t), do: t in @object_types 33 | 34 | @doc ~S""" 35 | This guard requires the value to be one of the four known git object types. 36 | """ 37 | defguard is_object_type(t) when t in @object_types 38 | 39 | @doc ~S""" 40 | Parses a byte list and converts it to an object-type atom. 41 | 42 | Returns `:error` if the byte list doesn't match any of the known-valid object types. 43 | """ 44 | @spec from_bytelist(value :: [byte]) :: t | :error 45 | def from_bytelist(value) 46 | 47 | def from_bytelist('blob'), do: cover(:blob) 48 | def from_bytelist('tree'), do: cover(:tree) 49 | def from_bytelist('commit'), do: cover(:commit) 50 | def from_bytelist('tag'), do: cover(:tag) 51 | def from_bytelist(value) when is_list(value), do: cover(:error) 52 | 53 | defmacro __using__(opts) do 54 | quote location: :keep, bind_quoted: [opts: opts] do 55 | alias Xgit.ObjectType 56 | import Xgit.ObjectType, only: [is_object_type: 1] 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/xgit/ref.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Ref do 2 | @moduledoc ~S""" 3 | A reference is a struct that describes a mutable pointer to a commit or similar object. 4 | 5 | A reference is a key-value pair where the key is a name in a specific format 6 | (see [`git check-ref-format`](https://git-scm.com/docs/git-check-ref-format)) 7 | and the value (`:target`) is either a SHA-1 hash or a reference to another reference key 8 | (i.e. `ref: (name-of-valid-ref)`). 9 | 10 | This structure contains the key-value pair and functions to validate both values. 11 | """ 12 | 13 | import Xgit.Util.ForceCoverage 14 | 15 | alias Xgit.ObjectId 16 | 17 | @typedoc ~S""" 18 | Name of a ref (typically `refs/heads/master` or similar). 19 | """ 20 | @type name :: String.t() 21 | 22 | @typedoc ~S""" 23 | Target for a ref. Can be either an `Xgit.ObjectId` or a string of the form 24 | `"ref: refs/..."`. 25 | """ 26 | @type target :: ObjectId.t() | String.t() 27 | 28 | @typedoc ~S""" 29 | This struct describes a single reference stored or about to be stored in a git 30 | repository. 31 | 32 | ## Struct Members 33 | 34 | * `:name`: the name of the reference (typically `refs/heads/master` or similar) 35 | * `:target`: the object ID currently marked by this reference or a symbolic link 36 | (`ref: refs/heads/master` or similar) to another reference 37 | * `:link_target`: the name of another reference which is targeted by this ref 38 | """ 39 | @type t :: %__MODULE__{ 40 | name: name(), 41 | target: target(), 42 | link_target: name() | nil 43 | } 44 | 45 | @enforce_keys [:name, :target] 46 | defstruct [:name, :target, :link_target] 47 | 48 | @doc ~S""" 49 | Return `true` if the string describes a valid reference name. 50 | """ 51 | @spec valid_name?(name :: any) :: boolean 52 | def valid_name?(name) when is_binary(name), do: valid_name?(name, false, false) 53 | def valid_name?(_), do: cover(false) 54 | 55 | @doc ~S""" 56 | Return `true` if the struct describes a valid reference. 57 | 58 | ## Options 59 | 60 | `allow_one_level?`: Set to `true` to disregard the rule requiring at least one `/` 61 | in name. (Similar to `--allow-onelevel` option.) 62 | 63 | `refspec?`: Set to `true` to allow a single `*` in the pattern. (Similar to 64 | `--refspec-pattern` option.) 65 | """ 66 | @spec valid?(ref :: any, allow_one_level?: boolean, refspec?: boolean) :: boolean 67 | def valid?(ref, opts \\ []) 68 | 69 | def valid?(%__MODULE__{name: name, target: target} = ref, opts) 70 | when is_binary(name) and is_binary(target) 71 | when is_list(opts) do 72 | valid_name?( 73 | name, 74 | Keyword.get(opts, :allow_one_level?, false), 75 | Keyword.get(opts, :refspec?, false) 76 | ) && valid_target?(target) && 77 | valid_name_or_nil?(Map.get(ref, :link_target)) 78 | end 79 | 80 | def valid?(_, _opts), do: cover(false) 81 | 82 | defp valid_name_or_nil?(nil), do: cover(true) 83 | defp valid_name_or_nil?("refs/" <> _ = target_name), do: cover(valid_name?(target_name)) 84 | defp valid_name_or_nil?(_), do: cover(false) 85 | 86 | defp valid_name?("@", _, _), do: cover(false) 87 | defp valid_name?("HEAD", _, _), do: cover(true) 88 | 89 | defp valid_name?(name, true, false) do 90 | all_components_valid?(name) && not Regex.match?(~r/[\x00-\x20\\\?\[:^\x7E\x7F]/, name) && 91 | not String.ends_with?(name, ".") && not String.contains?(name, "@{") 92 | end 93 | 94 | defp valid_name?(name, false, false) do 95 | String.contains?(name, "/") && valid_name?(name, true, false) && 96 | not String.contains?(name, "*") 97 | end 98 | 99 | defp valid_name?(name, false, true) do 100 | String.contains?(name, "/") && valid_name?(name, true, false) && at_most_one_asterisk?(name) 101 | end 102 | 103 | defp all_components_valid?(name) do 104 | name 105 | |> String.split("/") 106 | |> Enum.all?(&name_component_valid?/1) 107 | end 108 | 109 | defp name_component_valid?(component), do: not name_component_invalid?(component) 110 | 111 | defp name_component_invalid?(""), do: cover(true) 112 | 113 | defp name_component_invalid?(component) do 114 | String.starts_with?(component, ".") || 115 | String.ends_with?(component, ".lock") || 116 | String.contains?(component, "..") 117 | end 118 | 119 | @asterisk_re ~r/\*/ 120 | 121 | defp at_most_one_asterisk?(name) do 122 | @asterisk_re 123 | |> Regex.scan(name) 124 | |> Enum.count() 125 | |> Kernel.<=(1) 126 | end 127 | 128 | defp valid_target?(target), do: ObjectId.valid?(target) || valid_ref_target?(target) 129 | 130 | defp valid_ref_target?("ref: " <> target_name), 131 | do: valid_name?(target_name, false, false) && String.starts_with?(target_name, "refs/") 132 | 133 | defp valid_ref_target?(_), do: cover(false) 134 | end 135 | -------------------------------------------------------------------------------- /lib/xgit/repository/invalid_repository_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.InvalidRepositoryError do 2 | @moduledoc ~S""" 3 | Raised when a call is made to any `Xgit.Repository.*` API, but the 4 | process ID doesn't implement the `Xgit.Repository.Storage` API. 5 | """ 6 | 7 | defexception message: "not a valid Xgit repository" 8 | end 9 | -------------------------------------------------------------------------------- /lib/xgit/tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Tag do 2 | @moduledoc ~S""" 3 | Represents a git `tag` object in memory. 4 | """ 5 | alias Xgit.ContentSource 6 | alias Xgit.Object 7 | alias Xgit.ObjectId 8 | alias Xgit.ObjectType 9 | alias Xgit.PersonIdent 10 | alias Xgit.Ref 11 | 12 | use Xgit.ObjectType 13 | 14 | import Xgit.Util.ForceCoverage 15 | import Xgit.Util.ParseHeader, only: [next_header: 1] 16 | 17 | @typedoc ~S""" 18 | This struct describes a single `tag` object so it can be manipulated in memory. 19 | 20 | ## Struct Members 21 | 22 | * `:object`: (`Xgit.ObjectId`) object referenced by this tag 23 | * `:type`: (`Xgit.ObjectType`) type of the target object 24 | * `:name`: (bytelist) name of the tag 25 | * `:tagger`: (`Xgit.PersonIdent`) person who created the tag 26 | * `:message`: (bytelist) user-entered tag message (encoding unspecified) 27 | 28 | **TO DO:** Support signatures and other extensions. 29 | https://github.com/elixir-git/xgit/issues/202 30 | """ 31 | @type t :: %__MODULE__{ 32 | object: ObjectId.t(), 33 | type: ObjectType.t(), 34 | name: [byte], 35 | tagger: PersonIdent.t() | nil, 36 | message: [byte] 37 | } 38 | 39 | @enforce_keys [:object, :type, :name, :message] 40 | defstruct [:object, :type, :name, :message, tagger: nil] 41 | 42 | @doc ~S""" 43 | Return `true` if the value is a tag struct that is valid. 44 | """ 45 | @spec valid?(tag :: any) :: boolean 46 | def valid?(tag) 47 | 48 | def valid?(%__MODULE__{ 49 | object: object_id, 50 | type: object_type, 51 | name: name, 52 | tagger: tagger, 53 | message: message 54 | }) 55 | when is_binary(object_id) and is_object_type(object_type) and is_list(name) and 56 | is_list(message) do 57 | ObjectId.valid?(object_id) && 58 | not Enum.empty?(name) && 59 | (tagger == nil || PersonIdent.valid?(tagger)) && 60 | not Enum.empty?(message) 61 | end 62 | 63 | def valid?(_), do: cover(false) 64 | 65 | @doc ~S""" 66 | Return `true` if the value provided is valid as a tag name. 67 | """ 68 | @spec valid_name?(name :: any) :: boolean 69 | def valid_name?(name) when is_list(name), do: Ref.valid_name?("refs/tags/#{name}") 70 | def valid_name?(_name), do: cover(false) 71 | 72 | @typedoc ~S""" 73 | Error response codes returned by `from_object/1`. 74 | """ 75 | @type from_object_reason :: :not_a_tag | :invalid_tag 76 | 77 | @doc ~S""" 78 | Renders a tag structure from an `Xgit.Object`. 79 | 80 | ## Return Values 81 | 82 | `{:ok, tag}` if the object contains a valid `tag` object. 83 | 84 | `{:error, :not_a_tag}` if the object contains an object of a different type. 85 | 86 | `{:error, :invalid_tag}` if the object says that is of type `tag`, but 87 | can not be parsed as such. 88 | """ 89 | @spec from_object(object :: Object.t()) :: {:ok, tag :: t} | {:error, from_object_reason} 90 | def from_object(object) 91 | 92 | def from_object(%Object{type: :tag, content: content} = _object) do 93 | content 94 | |> ContentSource.stream() 95 | |> Enum.to_list() 96 | |> from_object_internal() 97 | end 98 | 99 | def from_object(%Object{} = _object), do: cover({:error, :not_a_tag}) 100 | 101 | defp from_object_internal(data) do 102 | with {:object, {'object', object_id_str, data}} <- {:object, next_header(data)}, 103 | {:object_id, {object_id, []}} <- {:object_id, ObjectId.from_hex_charlist(object_id_str)}, 104 | {:type_str, {'type', type_str, data}} <- {:type_str, next_header(data)}, 105 | {:type, type} when is_object_type(type) <- {:type, ObjectType.from_bytelist(type_str)}, 106 | {:name, {'tag', [_ | _] = name, data}} <- {:name, next_header(data)}, 107 | {:tagger_id, tagger, data} <- optional_tagger(data), 108 | message when is_list(message) <- drop_if_lf(data) do 109 | # TO DO: Support signatures and other extensions. 110 | # https://github.com/elixir-git/xgit/issues/202 111 | cover {:ok, 112 | %__MODULE__{ 113 | object: object_id, 114 | type: type, 115 | name: name, 116 | tagger: tagger, 117 | message: message 118 | }} 119 | else 120 | _ -> cover {:error, :invalid_tag} 121 | end 122 | end 123 | 124 | defp optional_tagger(data) do 125 | with {:tagger, {'tagger', tagger_str, data}} <- {:tagger, next_header(data)}, 126 | {:tagger_id, %PersonIdent{} = tagger} <- 127 | {:tagger_id, PersonIdent.from_byte_list(tagger_str)} do 128 | cover {:tagger_id, tagger, data} 129 | else 130 | {:tagger, :no_header_found} -> 131 | cover {:tagger_id, nil, data} 132 | 133 | {:tagger_id, x} -> 134 | cover {:tagger_error, x} 135 | end 136 | end 137 | 138 | defp drop_if_lf([10 | data]), do: cover(data) 139 | defp drop_if_lf([]), do: cover([]) 140 | defp drop_if_lf(_), do: cover(:error) 141 | 142 | @doc ~S""" 143 | Renders this tag structure into a corresponding `Xgit.Object`. 144 | 145 | If the tag structure is not valid, will raise `ArgumentError`. 146 | """ 147 | @spec to_object(commit :: t) :: Object.t() 148 | def to_object(commit) 149 | 150 | def to_object( 151 | %__MODULE__{ 152 | object: object_id, 153 | type: object_type, 154 | name: tag_name, 155 | tagger: %PersonIdent{} = tagger, 156 | message: message 157 | } = tag 158 | ) do 159 | unless valid?(tag) do 160 | raise ArgumentError, "Xgit.Tag.to_object/1: tag is not valid" 161 | end 162 | 163 | rendered_tag = 164 | 'object #{object_id}\n' ++ 165 | 'type #{object_type}\n' ++ 166 | 'tag #{tag_name}\n' ++ 167 | 'tagger #{PersonIdent.to_external_string(tagger)}\n' ++ 168 | '\n' ++ 169 | message 170 | 171 | # TO DO: Support signatures and other extensions. 172 | # https://github.com/elixir-git/xgit/issues/202 173 | 174 | cover %Object{ 175 | type: :tag, 176 | content: rendered_tag, 177 | size: Enum.count(rendered_tag), 178 | id: ObjectId.calculate_id(rendered_tag, :tag) 179 | } 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/xgit/tree.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Tree do 2 | @moduledoc ~S""" 3 | Represents a git `tree` object in memory. 4 | """ 5 | alias Xgit.ContentSource 6 | alias Xgit.FileMode 7 | alias Xgit.FilePath 8 | alias Xgit.Object 9 | alias Xgit.ObjectId 10 | 11 | import Xgit.Util.ForceCoverage 12 | 13 | @typedoc ~S""" 14 | This struct describes a single `tree` object so it can be manipulated in memory. 15 | 16 | ## Struct Members 17 | 18 | * `:entries`: list of `Tree.Entry` structs, which must be sorted by name 19 | """ 20 | @type t :: %__MODULE__{entries: [__MODULE__.Entry.t()]} 21 | 22 | @enforce_keys [:entries] 23 | defstruct [:entries] 24 | 25 | defmodule Entry do 26 | @moduledoc ~S""" 27 | A single file in a `tree` structure. 28 | """ 29 | 30 | use Xgit.FileMode 31 | 32 | alias Xgit.FileMode 33 | alias Xgit.FilePath 34 | alias Xgit.ObjectId 35 | alias Xgit.Util.Comparison 36 | 37 | import Xgit.Util.ForceCoverage 38 | 39 | @typedoc ~S""" 40 | A single file in a tree structure. 41 | 42 | ## Struct Members 43 | 44 | * `name`: (`FilePath.t`) entry path name, relative to top-level directory (without leading slash) 45 | * `object_id`: (`ObjectId.t`) SHA-1 for the represented object 46 | * `mode`: (`FileMode.t`) 47 | """ 48 | @type t :: %__MODULE__{ 49 | name: FilePath.t(), 50 | object_id: ObjectId.t(), 51 | mode: FileMode.t() 52 | } 53 | 54 | @enforce_keys [:name, :object_id, :mode] 55 | defstruct [:name, :object_id, :mode] 56 | 57 | @doc ~S""" 58 | Return `true` if this entry struct describes a valid tree entry. 59 | """ 60 | @spec valid?(entry :: any) :: boolean 61 | def valid?(entry) 62 | 63 | def valid?( 64 | %__MODULE__{ 65 | name: name, 66 | object_id: object_id, 67 | mode: mode 68 | } = _entry 69 | ) 70 | when is_list(name) and is_binary(object_id) and is_file_mode(mode) do 71 | FilePath.check_path_segment(name) == :ok && ObjectId.valid?(object_id) && 72 | object_id != ObjectId.zero() 73 | end 74 | 75 | def valid?(_), do: cover(false) 76 | 77 | @doc ~S""" 78 | Compare two entries according to git file name sorting rules. 79 | 80 | ## Return Value 81 | 82 | * `:lt` if `entry1` sorts before `entry2`. 83 | * `:eq` if they are the same. 84 | * `:gt` if `entry1` sorts after `entry2`. 85 | """ 86 | @spec compare(entry1 :: t | nil, entry2 :: t) :: Comparison.result() 87 | def compare(entry1, entry2) 88 | 89 | def compare(nil, _entry2), do: cover(:lt) 90 | 91 | def compare(%{name: name1} = _entry1, %{name: name2} = _entry2) do 92 | cond do 93 | name1 < name2 -> cover :lt 94 | name2 < name1 -> cover :gt 95 | true -> cover :eq 96 | end 97 | end 98 | end 99 | 100 | @doc ~S""" 101 | Return `true` if the value is a tree struct that is valid. 102 | 103 | All of the following must be true for this to occur: 104 | * The value is a `Tree` struct. 105 | * The entries are properly sorted. 106 | * All entries are valid, as determined by `Xgit.Tree.Entry.valid?/1`. 107 | """ 108 | @spec valid?(tree :: any) :: boolean 109 | def valid?(tree) 110 | 111 | def valid?(%__MODULE__{entries: entries}) when is_list(entries) do 112 | Enum.all?(entries, &Entry.valid?/1) && entries_sorted?([nil | entries]) 113 | end 114 | 115 | def valid?(_), do: cover(false) 116 | 117 | defp entries_sorted?([entry1, entry2 | tail]), 118 | do: Entry.compare(entry1, entry2) == :lt && entries_sorted?([entry2 | tail]) 119 | 120 | defp entries_sorted?([_]), do: cover(true) 121 | 122 | @typedoc ~S""" 123 | Error response codes returned by `from_object/1`. 124 | """ 125 | @type from_object_reason :: :not_a_tree | :invalid_format | :invalid_tree 126 | 127 | @doc ~S""" 128 | Renders a tree structure from an `Xgit.Object`. 129 | 130 | ## Return Values 131 | 132 | `{:ok, tree}` if the object contains a valid `tree` object. 133 | 134 | `{:error, :not_a_tree}` if the object contains an object of a different type. 135 | 136 | `{:error, :invalid_format}` if the object says that is of type `tree`, but 137 | can not be parsed as such. 138 | 139 | `{:error, :invalid_tree}` if the object can be parsed as a tree, but the 140 | entries are not well formed or not properly sorted. 141 | """ 142 | @spec from_object(object :: Object.t()) :: {:ok, tree :: t} | {:error, from_object_reason} 143 | def from_object(object) 144 | 145 | def from_object(%Object{type: :tree, content: content} = _object) do 146 | content 147 | |> ContentSource.stream() 148 | |> Enum.to_list() 149 | |> from_object_internal([]) 150 | end 151 | 152 | def from_object(%Object{} = _object), do: cover({:error, :not_a_tree}) 153 | 154 | defp from_object_internal(data, entries_acc) 155 | 156 | defp from_object_internal([], entries_acc) do 157 | tree = %__MODULE__{entries: Enum.reverse(entries_acc)} 158 | 159 | if valid?(tree) do 160 | cover {:ok, tree} 161 | else 162 | cover {:error, :invalid_tree} 163 | end 164 | end 165 | 166 | defp from_object_internal(data, entries_acc) do 167 | with {:ok, file_mode, data} <- parse_file_mode(data, 0), 168 | true <- FileMode.valid?(file_mode), 169 | {name, [0 | data]} <- path_and_object_id(data), 170 | :ok <- FilePath.check_path_segment(name), 171 | {raw_object_id, data} <- Enum.split(data, 20), 172 | 20 <- Enum.count(raw_object_id), 173 | false <- Enum.all?(raw_object_id, &(&1 == 0)) do 174 | this_entry = %__MODULE__.Entry{ 175 | name: name, 176 | mode: file_mode, 177 | object_id: ObjectId.from_binary_iodata(raw_object_id) 178 | } 179 | 180 | from_object_internal(data, [this_entry | entries_acc]) 181 | else 182 | _ -> cover {:error, :invalid_format} 183 | end 184 | end 185 | 186 | defp parse_file_mode([], _mode), do: cover({:error, :invalid_mode}) 187 | 188 | defp parse_file_mode([?\s | data], mode), do: cover({:ok, mode, data}) 189 | 190 | defp parse_file_mode([?0 | _data], 0), do: cover({:error, :invalid_mode}) 191 | 192 | defp parse_file_mode([c | data], mode) when c >= ?0 and c <= ?7, 193 | do: parse_file_mode(data, mode * 8 + (c - ?0)) 194 | 195 | defp parse_file_mode([_c | _data], _mode), do: cover({:error, :invalid_mode}) 196 | 197 | defp path_and_object_id(data), do: Enum.split_while(data, &(&1 != 0)) 198 | 199 | @doc ~S""" 200 | Renders this tree structure into a corresponding `Xgit.Object`. 201 | """ 202 | @spec to_object(tree :: t) :: Object.t() 203 | def to_object(tree) 204 | 205 | def to_object(%__MODULE__{entries: entries} = _tree) do 206 | rendered_entries = 207 | entries 208 | |> Enum.map(&entry_to_iodata/1) 209 | |> IO.iodata_to_binary() 210 | |> :binary.bin_to_list() 211 | 212 | %Object{ 213 | type: :tree, 214 | content: rendered_entries, 215 | size: Enum.count(rendered_entries), 216 | id: ObjectId.calculate_id(rendered_entries, :tree) 217 | } 218 | end 219 | 220 | defp entry_to_iodata(%__MODULE__.Entry{name: name, object_id: object_id, mode: mode}), 221 | do: cover([FileMode.to_short_octal(mode), ?\s, name, 0, ObjectId.to_binary_iodata(object_id)]) 222 | end 223 | -------------------------------------------------------------------------------- /lib/xgit/util/README.md: -------------------------------------------------------------------------------- 1 | **IMPORTANT:** As of version 0.3.0, the modules in this folder (named `Xgit.Util.*`) 2 | are not part of the public API of xgit. Though they are technically accessible from outside 3 | the xgit application, you should not depend on them. 4 | 5 | Changes to any `Xgit.Util.*` API will occur without notice and will not be considered 6 | API breaking for purposes of semantic versioning. 7 | -------------------------------------------------------------------------------- /lib/xgit/util/comparison.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.Comparison do 2 | @moduledoc false 3 | 4 | # Internal common vocabulary for data types that can be compared and/or sorted. 5 | 6 | @typedoc """ 7 | Result of a comparison. 8 | """ 9 | @type result :: :lt | :eq | :gt 10 | end 11 | -------------------------------------------------------------------------------- /lib/xgit/util/file_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.FileUtils do 2 | @moduledoc false 3 | 4 | # Internal utility for recursively listing the contents of a directory. 5 | 6 | import Xgit.Util.ForceCoverage 7 | 8 | @doc ~S""" 9 | Recursively list the files of a directory. 10 | 11 | Directories are scanned, but their paths are not reported as part of the result. 12 | """ 13 | @spec recursive_files!(path :: Path.t()) :: [Path.t()] 14 | def recursive_files!(path \\ ".") do 15 | cond do 16 | File.regular?(path) -> 17 | cover [path] 18 | 19 | File.dir?(path) -> 20 | path 21 | |> File.ls!() 22 | |> Enum.map(&Path.join(path, &1)) 23 | |> Enum.map(&recursive_files!/1) 24 | |> Enum.concat() 25 | 26 | true -> 27 | cover [] 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/xgit/util/force_coverage.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.ForceCoverage do 2 | @moduledoc false 3 | 4 | # This module is intended for internal testing purposes only. 5 | # We use it to wrap literal returns from functions in a way that 6 | # makes them visible to code coverage tools. 7 | 8 | # When building dev or production releases, we use a more efficient 9 | # form; when building for test (i.e. coverage), we use a more 10 | # complicated form that defeats compiler inlining. 11 | 12 | # Inspired by discussion at 13 | # https://elixirforum.com/t/functions-returning-a-literal-are-not-seen-by-code-coverage/16812. 14 | 15 | # coveralls-ignore-start 16 | 17 | if Application.get_env(:xgit, :use_force_coverage?) do 18 | defmacro cover(false = x) do 19 | quote do 20 | inspect(unquote(x)) 21 | unquote(x) 22 | end 23 | end 24 | 25 | defmacro cover(nil = x) do 26 | quote do 27 | inspect(unquote(x)) 28 | unquote(x) 29 | end 30 | end 31 | 32 | defmacro cover(value) do 33 | quote do 34 | # credo:disable-for-next-line Credo.Check.Warning.BoolOperationOnSameValues 35 | false or unquote(value) 36 | end 37 | end 38 | else 39 | defmacro cover(value) do 40 | quote do 41 | unquote(value) 42 | end 43 | end 44 | end 45 | 46 | # coveralls-ignore-stop 47 | end 48 | -------------------------------------------------------------------------------- /lib/xgit/util/nb.ex: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2008, 2015 Shawn O. Pearce 2 | # and other copyright owners as documented in the project's IP log. 3 | # 4 | # Elixir adaptation from jgit file: 5 | # org.eclipse.jgit/src/org/eclipse/jgit/util/NB.java 6 | # 7 | # Copyright (C) 2019, Eric Scouten 8 | # 9 | # This program and the accompanying materials are made available 10 | # under the terms of the Eclipse Distribution License v1.0 which 11 | # accompanies this distribution, is reproduced below, and is 12 | # available at http://www.eclipse.org/org/documents/edl-v10.php 13 | # 14 | # All rights reserved. 15 | # 16 | # Redistribution and use in source and binary forms, with or 17 | # without modification, are permitted provided that the following 18 | # conditions are met: 19 | # 20 | # - Redistributions of source code must retain the above copyright 21 | # notice, this list of conditions and the following disclaimer. 22 | # 23 | # - Redistributions in binary form must reproduce the above 24 | # copyright notice, this list of conditions and the following 25 | # disclaimer in the documentation and/or other materials provided 26 | # with the distribution. 27 | # 28 | # - Neither the name of the Eclipse Foundation, Inc. nor the 29 | # names of its contributors may be used to endorse or promote 30 | # products derived from this software without specific prior 31 | # written permission. 32 | # 33 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 34 | # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 35 | # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 36 | # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 37 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 38 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 39 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 40 | # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 41 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 42 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 43 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 44 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 45 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 46 | 47 | defmodule Xgit.Util.NB do 48 | @moduledoc false 49 | 50 | # Internal conversion utilities for network byte order handling. 51 | 52 | use Bitwise 53 | 54 | import Xgit.Util.ForceCoverage 55 | 56 | @doc ~S""" 57 | Parses a sequence of 4 bytes (network byte order) as a signed integer. 58 | 59 | Reads the first four bytes from `intbuf` and returns `{value, buf}` 60 | where value is the integer value from the first four bytes at `intbuf` 61 | and `buf` is the remainder of the byte array after those bytes. 62 | """ 63 | @spec decode_int32(intbuf :: [byte]) :: {integer, [byte]} 64 | def decode_int32(intbuf) 65 | 66 | def decode_int32([b1, b2, b3, b4 | tail]) when b1 >= 128, 67 | do: cover({b1 * 0x1000000 + b2 * 0x10000 + b3 * 0x100 + b4 - 0x100000000, tail}) 68 | 69 | def decode_int32([b1, b2, b3, b4 | tail]), 70 | do: cover({b1 * 0x1000000 + b2 * 0x10000 + b3 * 0x100 + b4, tail}) 71 | 72 | @doc ~S""" 73 | Parses a sequence of 2 bytes (network byte order) as an unsigned integer. 74 | 75 | Reads the first four bytes from `intbuf` and returns `{value, buf}` 76 | where value is the unsigned integer value from the first two bytes at `intbuf` 77 | and `buf` is the remainder of the byte array after those bytes. 78 | """ 79 | @spec decode_uint16(intbuf :: [byte]) :: {integer, [byte]} 80 | def decode_uint16(intbuf) 81 | def decode_uint16([b1, b2 | tail]), do: cover({b1 * 0x100 + b2, tail}) 82 | 83 | @doc ~S""" 84 | Parses a sequence of 4 bytes (network byte order) as an unsigned integer. 85 | 86 | Reads the first four bytes from `intbuf` and returns `{value, buf}` 87 | where value is the unsigned integer value from the first four bytes at `intbuf` 88 | and `buf` is the remainder of the byte array after those bytes. 89 | """ 90 | @spec decode_uint32(intbuf :: [byte]) :: {integer, [byte]} 91 | def decode_uint32(intbuf) 92 | 93 | def decode_uint32([b1, b2, b3, b4 | tail]), 94 | do: cover({b1 * 0x1000000 + b2 * 0x10000 + b3 * 0x100 + b4, tail}) 95 | 96 | @doc ~S""" 97 | Convert a 16-bit integer to a sequence of two bytes in network byte order. 98 | """ 99 | @spec encode_int16(v :: integer) :: [byte] 100 | def encode_int16(v) when is_integer(v) and v >= -32_768 and v <= 65_535, 101 | do: cover([v >>> 8 &&& 0xFF, v &&& 0xFF]) 102 | 103 | @doc ~S""" 104 | Convert a 32-bit integer to a sequence of four bytes in network byte order. 105 | """ 106 | @spec encode_int32(v :: integer) :: [byte] 107 | def encode_int32(v) when is_integer(v) and v >= -2_147_483_647 and v <= 4_294_967_295, 108 | do: cover([v >>> 24 &&& 0xFF, v >>> 16 &&& 0xFF, v >>> 8 &&& 0xFF, v &&& 0xFF]) 109 | 110 | @doc ~S""" 111 | Convert a 16-bit unsigned integer to a sequence of two bytes in network byte order. 112 | """ 113 | @spec encode_uint16(v :: non_neg_integer) :: [byte] 114 | def encode_uint16(v) when is_integer(v) and v >= 0 and v <= 65_535, 115 | do: cover([v >>> 8 &&& 0xFF, v &&& 0xFF]) 116 | 117 | @doc ~S""" 118 | Convert a 32-bit unsigned integer to a sequence of four bytes in network byte order. 119 | """ 120 | @spec encode_uint32(v :: non_neg_integer) :: [byte] 121 | def encode_uint32(v) when is_integer(v) and v >= 0 and v <= 4_294_967_295, 122 | do: cover([v >>> 24 &&& 0xFF, v >>> 16 &&& 0xFF, v >>> 8 &&& 0xFF, v &&& 0xFF]) 123 | end 124 | -------------------------------------------------------------------------------- /lib/xgit/util/observed_file.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.ObservedFile do 2 | @moduledoc false 3 | 4 | # Records the cached parsed state of the file and its modification date 5 | # so that Xgit can avoid the work of re-parsing that file when we can 6 | # be sure it is unchanged. 7 | 8 | import Xgit.Util.ForceCoverage 9 | 10 | @typedoc ~S""" 11 | Cache for parsed state of the file and information about its 12 | file system state. 13 | 14 | ## Struct Members 15 | 16 | * `path`: path to the file 17 | * `exists?`: `true` if the file existed last time we checked 18 | * `last_modified_time`: POSIX file time for the file last time we checked 19 | (`nil` if file did not exist then) 20 | * `last_checked_time`: POSIX time stamp when file status was checked 21 | (used to help avoid the "racy git problem") 22 | * `parsed_state`: result from either `parse_fn` or `empty_fn` 23 | """ 24 | @type t :: %__MODULE__{ 25 | path: Path.t(), 26 | exists?: boolean, 27 | last_modified_time: integer | nil, 28 | last_checked_time: integer | nil, 29 | parsed_state: any 30 | } 31 | 32 | @typedoc ~S""" 33 | A function that parses a file at a given path and returns a parsed state 34 | for that file. 35 | """ 36 | @type parse_fn :: (Path.t() -> any) 37 | 38 | @typedoc ~S""" 39 | A function that can return a state for the file format when the file 40 | doesn't exist. 41 | """ 42 | @type empty_fn :: (() -> any) 43 | 44 | @enforce_keys [:path, :exists?, :parsed_state] 45 | defstruct [ 46 | :path, 47 | :exists?, 48 | :last_modified_time, 49 | :last_checked_time, 50 | :parsed_state 51 | ] 52 | 53 | @doc ~S""" 54 | Record an initial observation of the contents of the file. 55 | 56 | ## Parameters 57 | 58 | `parse_fn` is a function with one argument (path) that parses the file 59 | if it exists and returns the content that will be stored in `parsed_state`. 60 | 61 | `empty_fn` is a function with zero arguments that returns the desired state 62 | for `parsed_state` in the event there is no file at this path. 63 | """ 64 | @spec initial_state_for_path(path :: Path.t(), parse_fn :: parse_fn, empty_fn :: empty_fn) :: t 65 | def initial_state_for_path(path, parse_fn, empty_fn) 66 | when is_binary(path) and is_function(parse_fn, 1) and is_function(empty_fn, 0), 67 | do: state_from_file_stat(path, parse_fn, empty_fn, File.stat(path, time: :posix)) 68 | 69 | defp state_from_file_stat(path, parse_fn, _empty_fn, {:ok, %{type: :regular, mtime: mtime}}) do 70 | %__MODULE__{ 71 | path: path, 72 | exists?: true, 73 | last_modified_time: mtime, 74 | last_checked_time: System.os_time(:second), 75 | parsed_state: parse_fn.(path) 76 | } 77 | end 78 | 79 | defp state_from_file_stat(path, _parse_fn, _empty_fn, {:ok, %{type: file_type}}) do 80 | raise ArgumentError, 81 | "Xgit.Util.ObservedFile: path #{path} points to an item of type #{file_type}; should be a regular file or no file at all" 82 | end 83 | 84 | defp state_from_file_stat(path, _parse_fn, empty_fn, {:error, :enoent}) do 85 | %__MODULE__{ 86 | path: path, 87 | exists?: false, 88 | parsed_state: empty_fn.() 89 | } 90 | end 91 | 92 | @doc ~S""" 93 | Return `true` if the file has potentially changed since the last 94 | recorded observation. This can happen if: 95 | 96 | * The modified time has changed since the previous observation. 97 | * The file exists when it did not previously exist (or vice versa). 98 | * The modified time is so recent as to be indistinguishable from 99 | the time at which the initial snapshot was recorded. (This is often 100 | referred to as the "racy git problem.") 101 | 102 | This function does not update the cached state of the file. 103 | """ 104 | @spec maybe_dirty?(observed_file :: t) :: boolean 105 | def maybe_dirty?(%__MODULE__{path: path} = observed_file) when is_binary(path), 106 | do: maybe_dirty_for_file_stat?(observed_file, File.stat(path, time: :posix)) 107 | 108 | defp maybe_dirty_for_file_stat?( 109 | %__MODULE__{ 110 | exists?: true, 111 | last_modified_time: last_modified_time, 112 | last_checked_time: last_checked_time 113 | }, 114 | {:ok, %File.Stat{type: :regular, mtime: last_modified_time}} 115 | ) 116 | when is_integer(last_modified_time) do 117 | # File still exists and modified time is same as before. Are we in racy git state? 118 | # Certain file systems round to the nearest few seconds, so last mod time has 119 | # to be at least 3 seconds before we checked status for us to start believing file content. 120 | 121 | last_modified_time >= last_checked_time - 2 122 | end 123 | 124 | defp maybe_dirty_for_file_stat?( 125 | %__MODULE__{exists?: true, last_modified_time: lmt1}, 126 | {:ok, %File.Stat{type: :regular, mtime: lmt2}} 127 | ) 128 | when is_integer(lmt1) and is_integer(lmt2) do 129 | # File still exists but modified time doesn't match: Dirty. 130 | cover true 131 | end 132 | 133 | defp maybe_dirty_for_file_stat?(%__MODULE__{exists?: false}, {:error, :enoent}) do 134 | # File didn't exist before; still doesn't: Not dirty. 135 | cover false 136 | end 137 | 138 | defp maybe_dirty_for_file_stat?(%__MODULE__{exists?: false}, {:ok, %File.Stat{type: :regular}}) do 139 | # File didn't exist before; it does now. 140 | cover true 141 | end 142 | 143 | defp maybe_dirty_for_file_stat?(%__MODULE__{exists?: true}, {:error, :enoent}) do 144 | # File existed before; now it doesn't. 145 | cover true 146 | end 147 | 148 | defp maybe_dirty_for_file_stat?(%__MODULE__{path: path}, {:ok, %{type: file_type}}) do 149 | raise ArgumentError, 150 | "Xgit.Util.ObservedFile: path #{path} points to an item of type #{file_type}; should be a regular file or no file at all" 151 | end 152 | 153 | @doc ~S""" 154 | Update the cached state of the file if it has potentially changed since the last 155 | observation. 156 | 157 | As noted in `maybe_dirty?/1`, we err on the side of caution if the modification date 158 | alone can not be trusted to reflect changes to the file's content. 159 | 160 | ## Parameters 161 | 162 | `parse_fn` is a function with one argument (path) that parses the file 163 | if it exists and returns the content that will be stored in `parsed_state`. 164 | 165 | `empty_fn` is a function with zero arguments that returns the desired state 166 | for `parsed_state` in the event there is no file at this path. 167 | 168 | If the file state has potentially changed (see `maybe_dirty?/1`) then either 169 | `parse_fn` or `empty_fn` will be called to generate a new value for `parsed_state`. 170 | 171 | ## Return Value 172 | 173 | Returns an `ObservedFile` struct which may have been updated via either `parse_fn/1` 174 | or `empty_fn/0` as appropriate. 175 | """ 176 | @spec update_state_if_maybe_dirty( 177 | observed_file :: t, 178 | parse_fn :: parse_fn, 179 | empty_fn :: empty_fn 180 | ) :: t 181 | def update_state_if_maybe_dirty(%__MODULE__{path: path} = observed_file, parse_fn, empty_fn) 182 | when is_binary(path) and is_function(parse_fn, 1) and is_function(empty_fn, 0) do 183 | file_stat = File.stat(path, time: :posix) 184 | 185 | if maybe_dirty_for_file_stat?(observed_file, file_stat) do 186 | state_from_file_stat(path, parse_fn, empty_fn, file_stat) 187 | else 188 | # We're sure the file is unchanged: Return cached state as is. 189 | cover observed_file 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /lib/xgit/util/parse_charlist.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.ParseCharlist do 2 | @moduledoc false 3 | 4 | # Internal utility for parsing charlists with ambiguous encodings. 5 | 6 | import Xgit.Util.ForceCoverage 7 | 8 | @doc ~S""" 9 | Convert a list of bytes to an Elixir (UTF-8) string when the encoding is not 10 | definitively known. Try parsing as a UTF-8 byte array first, then try ISO-8859-1. 11 | """ 12 | @spec decode_ambiguous_charlist(b :: [byte]) :: String.t() 13 | def decode_ambiguous_charlist(b) when is_list(b) do 14 | raw = :erlang.list_to_binary(b) 15 | 16 | case :unicode.characters_to_binary(raw) do 17 | utf8 when is_binary(utf8) -> cover(utf8) 18 | _ -> :unicode.characters_to_binary(raw, :latin1) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/xgit/util/parse_decimal.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.ParseDecimal do 2 | @moduledoc false 3 | 4 | # Internal utility for parsing decimal values from charlist. 5 | 6 | import Xgit.Util.ForceCoverage 7 | 8 | @doc ~S""" 9 | Parse a base-10 numeric value from a charlist of ASCII digits into a number. 10 | 11 | Similar to `Integer.parse/2` but uses charlist instead. 12 | 13 | Digit sequences can begin with an optional run of spaces before the 14 | sequence, and may start with a `+` or a `-` to indicate sign position. 15 | Any other characters will cause the method to stop and return the current 16 | result to the caller. 17 | 18 | Returns `{number, new_buffer}` where `number` is the integer that was 19 | found (or 0 if no number found there) and `new_buffer` is the charlist 20 | following the number that was parsed. 21 | """ 22 | @spec from_decimal_charlist(b :: charlist) :: {integer, charlist} 23 | def from_decimal_charlist(b) when is_list(b) do 24 | b = skip_white_space(b) 25 | {sign, b} = parse_sign(b) 26 | {n, b} = parse_digits(0, b) 27 | 28 | cover {sign * n, b} 29 | end 30 | 31 | defp skip_white_space([?\s | b]), do: skip_white_space(b) 32 | defp skip_white_space(b), do: b 33 | 34 | defp parse_sign([?- | b]), do: cover({-1, b}) 35 | defp parse_sign([?+ | b]), do: cover({1, b}) 36 | defp parse_sign(b), do: cover({1, b}) 37 | 38 | defp parse_digits(n, [d | b]) when d >= ?0 and d <= ?9, do: parse_digits(n * 10 + (d - ?0), b) 39 | defp parse_digits(n, b), do: cover({n, b}) 40 | end 41 | -------------------------------------------------------------------------------- /lib/xgit/util/parse_header.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.ParseHeader do 2 | @moduledoc false 3 | 4 | # Internal utility for parsing headers from commit and tag objects. 5 | 6 | import Xgit.Util.ForceCoverage 7 | 8 | @doc ~S""" 9 | Returns the next header that can be parsed from the charlist `b`. 10 | 11 | As of this writing, will not parse headers that span multiple lines. 12 | This may be added later if needed. 13 | 14 | ## Return Values 15 | 16 | `{'header_name', 'header_value', next_data}` if a header is successfully 17 | identified. `next_data` will be advanced immediately past the LF that 18 | terminates this header. 19 | 20 | `:no_header_found` if unable to find a header at this location. 21 | """ 22 | @spec next_header(b :: charlist) :: 23 | {header :: charlist, value :: charlist, next_data :: charlist} | :no_header_found 24 | def next_header(b) when is_list(b) do 25 | with {[_ | _] = header, [?\s | next]} <- Enum.split_while(b, &header_char?/1), 26 | {value, next} <- Enum.split_while(next, &value_char?/1) do 27 | cover {header, value, skip_next_lf(next)} 28 | else 29 | _ -> cover :no_header_found 30 | end 31 | end 32 | 33 | defp header_char?(32), do: cover(false) 34 | defp header_char?(10), do: cover(false) 35 | defp header_char?(_), do: cover(true) 36 | 37 | defp value_char?(10), do: cover(false) 38 | defp value_char?(_), do: cover(true) 39 | 40 | defp skip_next_lf([10 | next]), do: cover(next) 41 | defp skip_next_lf(next), do: cover(next) 42 | end 43 | -------------------------------------------------------------------------------- /lib/xgit/util/shared_test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.SharedTestCase do 2 | @moduledoc false 3 | 4 | # Code to encourage sharing of test cases. 5 | # Adapted from https://blog.codeminer42.com/how-to-test-shared-behavior-in-elixir-3ea3ebb92b64/. 6 | 7 | defmacro define_shared_tests(do: block) do 8 | quote do 9 | defmacro __using__(options) do 10 | block = unquote(Macro.escape(block)) 11 | 12 | async? = Keyword.get(options, :async, false) 13 | options_without_async = Keyword.delete(options, :async) 14 | 15 | quote do 16 | use ExUnit.Case, async: unquote(async?) 17 | 18 | @moduletag unquote(options_without_async) 19 | unquote(block) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/xgit/util/unzip_stream.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.UnzipStream do 2 | @moduledoc false 3 | 4 | # Internal utility that transforms a stream from a compressed 5 | # ZIP stream to uncompressed data. 6 | 7 | import Xgit.Util.ForceCoverage 8 | 9 | @doc ~S""" 10 | Transforms a stream from a compressed ZIP stream to uncompressed data. 11 | """ 12 | @spec unzip(compressed_stream :: Enum.t()) :: Enum.t() 13 | def unzip(compressed_stream), 14 | do: Stream.transform(compressed_stream, &start/0, &process_data/2, &finish/1) 15 | 16 | defp start do 17 | z = :zlib.open() 18 | :ok = :zlib.inflateInit(z) 19 | z 20 | end 21 | 22 | defp process_data(compressed_data, z) do 23 | cover {compressed_data 24 | |> process_all_data(z, []) 25 | |> Enum.reverse() 26 | |> Enum.concat(), z} 27 | end 28 | 29 | defp process_all_data(compressed_data, z, uncompressed_data_acc) do 30 | {status, iodata} = :zlib.safeInflate(z, compressed_data) 31 | 32 | case status do 33 | :continue -> 34 | process_all_data([], z, [to_byte_list(iodata) | uncompressed_data_acc]) 35 | 36 | :finished -> 37 | cover [to_byte_list(iodata) | uncompressed_data_acc] 38 | end 39 | end 40 | 41 | defp to_byte_list([]), do: cover([]) 42 | defp to_byte_list([b]) when is_binary(b), do: :binary.bin_to_list(b) 43 | 44 | defp finish(z) do 45 | :ok = :zlib.inflateEnd(z) 46 | :ok = :zlib.close(z) 47 | cover nil 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.8.0" 5 | 6 | def project do 7 | [ 8 | app: :xgit, 9 | version: @version, 10 | name: "Xgit", 11 | elixir: "~> 1.8", 12 | elixirc_options: [warnings_as_errors: true], 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | elixirc_paths: elixirc_paths(Mix.env()), 16 | build_per_environment: false, 17 | test_coverage: [tool: ExCoveralls], 18 | description: description(), 19 | package: package(), 20 | docs: docs() 21 | ] 22 | end 23 | 24 | def application, do: [mod: {Xgit, []}, extra_applications: [:logger]] 25 | 26 | defp deps do 27 | [ 28 | {:benchee, "~> 1.0", only: :dev}, 29 | {:credo, "~> 1.1", only: [:dev, :test]}, 30 | {:dialyxir, "~> 1.0.0-rc.6", only: :dev, runtime: false}, 31 | {:excoveralls, "~> 0.11", only: :test}, 32 | {:ex_doc, "~> 0.21", only: :dev}, 33 | {:temp, "~> 0.4", only: [:dev, :test]} 34 | ] 35 | end 36 | 37 | defp description, do: "Pure Elixir native implementation of git" 38 | 39 | defp package do 40 | [ 41 | maintainers: ["Eric Scouten"], 42 | licenses: ["Apache2"], 43 | links: %{"Github" => "https://github.com/elixir-git/xgit", "Reflog" => "https://xgit.io"} 44 | ] 45 | end 46 | 47 | defp docs do 48 | [ 49 | main: "Xgit", 50 | source_ref: "v#{@version}", 51 | logo: "branding/xgit-logo.png", 52 | canonical: "http://hexdocs.pm/xgit", 53 | source_url: "https://github.com/elixir-git/xgit", 54 | homepage_url: "https://xgit.io", 55 | groups_for_modules: [ 56 | Repository: [ 57 | "Xgit.Repository", 58 | "Xgit.Repository.InMemory", 59 | "Xgit.Repository.OnDisk", 60 | "Xgit.Repository.Plumbing", 61 | "Xgit.Repository.Storage", 62 | "Xgit.Repository.WorkingTree" 63 | ], 64 | "Core Data Model": [ 65 | "Xgit.Commit", 66 | "Xgit.Config", 67 | "Xgit.ConfigEntry", 68 | "Xgit.ConfigFile", 69 | "Xgit.ContentSource", 70 | "Xgit.DirCache", 71 | "Xgit.DirCache.Entry", 72 | "Xgit.FileContentSource", 73 | "Xgit.FileMode", 74 | "Xgit.FilePath", 75 | "Xgit.Object", 76 | "Xgit.ObjectId", 77 | "Xgit.ObjectType", 78 | "Xgit.PersonIdent", 79 | "Xgit.Ref", 80 | "Xgit.Tag", 81 | "Xgit.Tree", 82 | "Xgit.Tree.Entry" 83 | ] 84 | ] 85 | ] 86 | end 87 | 88 | defp elixirc_paths(:test), do: ["lib", "test/support"] 89 | defp elixirc_paths(_), do: ["lib"] 90 | end 91 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, 3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 4 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 5 | "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, 6 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 7 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, 8 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"}, 11 | "excoveralls": {:hex, :excoveralls, "0.12.3", "2142be7cb978a3ae78385487edda6d1aff0e482ffc6123877bb7270a8ffbcfe0", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "568a3e616c264283f5dea5b020783ae40eef3f7ee2163f7a67cbd7b35bcadada"}, 12 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, 13 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 14 | "jason": {:hex, :jason, "1.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, 15 | "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 17 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 18 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, 20 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 21 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, 22 | "temp": {:hex, :temp, "0.4.7", "2c78482cc2294020a4bc0c95950b907ff386523367d4e63308a252feffbea9f2", [:mix], [], "hexpm", "6af19e7d6a85a427478be1021574d1ae2a1e1b90882586f06bde76c63cd03e0d"}, 23 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/LICENSE_blob.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-git/xgit/0e2f849c83cdf39a9249b319d63ff3682c482c2f/test/fixtures/LICENSE_blob.zip -------------------------------------------------------------------------------- /test/fixtures/test_content.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-git/xgit/0e2f849c83cdf39a9249b319d63ff3682c482c2f/test/fixtures/test_content.zip -------------------------------------------------------------------------------- /test/support/folder_diff.ex: -------------------------------------------------------------------------------- 1 | defmodule FolderDiff do 2 | @moduledoc false 3 | 4 | # Split out as a separate package? Compare two folders. Assert if mismatched. 5 | 6 | import ExUnit.Assertions 7 | 8 | @spec assert_folders_are_equal(folder1 :: Path.t(), folder2 :: Path.t()) :: :ok 9 | def assert_folders_are_equal(folder1, folder2) do 10 | files1 = folder1 |> File.ls!() |> Enum.sort() 11 | files2 = folder2 |> File.ls!() |> Enum.sort() 12 | 13 | assert_folders_are_equal(folder1, folder2, files1, files2) 14 | end 15 | 16 | defp assert_folders_are_equal(folder1, folder2, [file1 | files1], [file2 | files2]) do 17 | cond do 18 | file1 == file2 -> 19 | assert_paths_are_equal(folder1, folder2, file1) 20 | assert_folders_are_equal(folder1, folder2, files1, files2) 21 | 22 | file1 < file2 -> 23 | flunk_file_missing(folder1, folder2, file1) 24 | 25 | true -> 26 | flunk_file_missing(folder2, folder1, file2) 27 | end 28 | 29 | :ok 30 | end 31 | 32 | defp assert_folders_are_equal(folder1, folder2, [file1 | _], []), 33 | do: flunk_file_missing(folder1, folder2, file1) 34 | 35 | defp assert_folders_are_equal(folder1, folder2, [], [file2 | _]), 36 | do: flunk_file_missing(folder2, folder1, file2) 37 | 38 | defp assert_folders_are_equal(_folder1, _folder2, [], []), do: :ok 39 | 40 | defp assert_paths_are_equal(_folder1, _folder2, "."), do: :ok 41 | defp assert_paths_are_equal(_folder1, _folder2, ".."), do: :ok 42 | 43 | defp assert_paths_are_equal(folder1, folder2, file) do 44 | f1 = Path.join(folder1, file) 45 | f2 = Path.join(folder2, file) 46 | 47 | f1_is_dir? = File.dir?(f1) 48 | f2_is_dir? = File.dir?(f2) 49 | 50 | cond do 51 | f1_is_dir? and f2_is_dir? -> assert_folders_are_equal(f1, f2) 52 | f1_is_dir? -> flunk("#{f1} is a directory; #{f2} is a file") 53 | f2_is_dir? -> flunk("#{f1} is a file; #{f2} is a directory") 54 | true -> assert_files_are_equal(f1, f2) 55 | end 56 | end 57 | 58 | defp flunk_file_missing(folder_present, folder_missing, file) do 59 | flunk("File #{file} exists in folder #{folder_present}, but is missing in #{folder_missing}") 60 | end 61 | 62 | @spec assert_files_are_equal(f1 :: Path.t(), f2 :: Path.t()) :: :ok 63 | def assert_files_are_equal(f1, f2) do 64 | c1 = File.read!(f1) 65 | c2 = File.read!(f2) 66 | 67 | unless c1 == c2 do 68 | c1 = truncate(c1) 69 | c2 = truncate(c2) 70 | 71 | flunk(~s""" 72 | Files mismatch: 73 | 74 | #{f1}: 75 | #{c1} 76 | 77 | #{f2}: 78 | #{c2} 79 | 80 | """) 81 | end 82 | 83 | :ok 84 | end 85 | 86 | defp truncate(c) do 87 | length = String.length(c) 88 | 89 | if String.valid?(c) do 90 | if length > 500 do 91 | ~s""" 92 | #{length} bytes starting with: 93 | #{String.slice(c, 0, 500)} 94 | """ 95 | else 96 | c 97 | end 98 | else 99 | if length > 100 do 100 | ~s""" 101 | #{length} bytes starting with: 102 | #{inspect(:binary.bin_to_list(c, 0, 100))} 103 | """ 104 | else 105 | inspect(:binary.bin_to_list(c)) 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/support/not_valid.ex: -------------------------------------------------------------------------------- 1 | defmodule NotValid do 2 | @moduledoc false 3 | 4 | # This module can be used to test the invalid case for Repository.valid? 5 | # and similar. It will not respond properly to such messages. 6 | 7 | use GenServer 8 | 9 | @impl true 10 | def init(nil), do: {:ok, nil} 11 | 12 | @impl true 13 | def handle_call(_request, _from, _state), do: {:reply, :whatever, nil} 14 | end 15 | -------------------------------------------------------------------------------- /test/support/test/on_disk_repo_test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Test.OnDiskRepoTestCase do 2 | @moduledoc false 3 | # (Testing only) Test case that sets up a temporary directory with an on-disk repository. 4 | use ExUnit.CaseTemplate 5 | 6 | alias Xgit.Repository.OnDisk 7 | alias Xgit.Test.TempDirTestCase 8 | 9 | setup do 10 | {:ok, repo!()} 11 | end 12 | 13 | @doc ~S""" 14 | Returns a context with an on-disk repository set up. 15 | 16 | ## Options 17 | 18 | * `path:` (`Path`) if present, uses the named path for the git repo 19 | instead of the default (temporary directory). The directory will be 20 | erased at the start of the test and left in place afterwards. 21 | Use this option when you need to debug a test that is failing and 22 | you want to inspect the repo after the test completes. 23 | 24 | * `config_file_content:` (`String` | nil) optional override for `.git/config` 25 | file (a string means use this content; `nil` means do not create the file) 26 | """ 27 | @spec repo!(path: Path.t(), config_file_content: String.t()) :: %{ 28 | tmp_dir: Path.t(), 29 | config_file_path: Path.t(), 30 | xgit_path: Path.t(), 31 | xgit_repo: Storage.t() 32 | } 33 | def repo!(opts \\ []) when is_list(opts) do 34 | context = 35 | case Keyword.get(opts, :path) do 36 | nil -> 37 | TempDirTestCase.tmp_dir!() 38 | 39 | path when is_binary(path) -> 40 | File.rm_rf!(path) 41 | File.mkdir!(path) 42 | %{tmp_dir: path} 43 | end 44 | 45 | git_init_repo(context, opts) 46 | end 47 | 48 | defp git_init_repo(%{tmp_dir: xgit_path} = context, opts) do 49 | git_init_and_standardize(xgit_path, opts) 50 | 51 | {:ok, xgit_repo} = OnDisk.start_link(work_dir: xgit_path) 52 | 53 | Map.merge(context, %{ 54 | xgit_path: xgit_path, 55 | xgit_repo: xgit_repo, 56 | config_file_path: Path.join(xgit_path, ".git/config") 57 | }) 58 | end 59 | 60 | @default_config_file_content ~s""" 61 | [core] 62 | \trepositoryformatversion = 0 63 | \tfilemode = true 64 | \tbare = false 65 | \tlogallrefupdates = true 66 | """ 67 | defp git_init_and_standardize(git_dir, opts) do 68 | git_dir 69 | |> git_init() 70 | |> remove_branches_dir() 71 | |> remove_sample_hooks() 72 | |> rewrite_config(Keyword.get(opts, :config_file_content, @default_config_file_content)) 73 | |> rewrite_info_exclude() 74 | end 75 | 76 | defp git_init(git_dir) do 77 | {_, 0} = System.cmd("git", ["init", git_dir]) 78 | git_dir 79 | end 80 | 81 | defp remove_branches_dir(git_dir) do 82 | branches_dir = Path.join(git_dir, ".git/branches") 83 | if File.dir?(branches_dir), do: File.rm_rf!(branches_dir) 84 | 85 | git_dir 86 | end 87 | 88 | defp remove_sample_hooks(git_dir) do 89 | hooks_dir = Path.join(git_dir, ".git/hooks") 90 | 91 | hooks_dir 92 | |> File.ls!() 93 | |> Enum.filter(&String.ends_with?(&1, ".sample")) 94 | |> Enum.each(&File.rm!(Path.join(hooks_dir, &1))) 95 | 96 | git_dir 97 | end 98 | 99 | defp rewrite_config(git_dir, config_file_content) when is_binary(config_file_content) do 100 | git_dir 101 | |> Path.join(".git/config") 102 | |> File.write!(config_file_content) 103 | 104 | git_dir 105 | end 106 | 107 | defp rewrite_config(git_dir, nil) do 108 | git_dir 109 | |> Path.join(".git/config") 110 | |> File.rm() 111 | 112 | git_dir 113 | end 114 | 115 | defp rewrite_info_exclude(git_dir) do 116 | git_dir 117 | |> Path.join(".git/info/exclude") 118 | |> File.write!(~s""" 119 | # git ls-files --others --exclude-from=.git/info/exclude 120 | # Lines that start with '#' are comments. 121 | # For a project mostly in C, the following would be a good set of 122 | # exclude patterns (uncomment them if you want to use them): 123 | # *.[oa] 124 | # *~ 125 | .DS_Store 126 | """) 127 | 128 | git_dir 129 | end 130 | 131 | @doc ~S""" 132 | Returns a pre-configured environment for a known author/committer ID and timestamp. 133 | """ 134 | @spec sample_commit_env() :: [{String.t(), String.t()}] 135 | def sample_commit_env do 136 | [ 137 | {"GIT_AUTHOR_DATE", "1142878449 +0230"}, 138 | {"GIT_COMMITTER_DATE", "1142878449 +0230"}, 139 | {"GIT_AUTHOR_EMAIL", "author@example.com"}, 140 | {"GIT_COMMITTER_EMAIL", "author@example.com"}, 141 | {"GIT_AUTHOR_NAME", "A. U. Thor"}, 142 | {"GIT_COMMITTER_NAME", "A. U. Thor"} 143 | ] 144 | end 145 | 146 | @doc ~S""" 147 | Returns a context with an on-disk repository set up. 148 | 149 | This repository has a tree object with one file in it. 150 | 151 | Can optionally take a hard-wired path to use instead of the default 152 | temporary directory. Use that when you need to debug a test that is 153 | failing and you want to inspect the repo after the test completes. 154 | """ 155 | @spec setup_with_valid_tree!(path: Path.t(), config_file_content: String.t()) :: %{ 156 | tmp_dir: Path.t(), 157 | config_file_path: Path.t(), 158 | xgit_path: Path.t(), 159 | xgit_repo: Storage.t(), 160 | tree_id: binary() 161 | } 162 | def setup_with_valid_tree!(opts \\ []) when is_list(opts) do 163 | %{xgit_path: xgit_path} = context = repo!(opts) 164 | 165 | test_content_path = Temp.path!() 166 | File.write!(test_content_path, "test content\n") 167 | 168 | {object_id_str, 0} = 169 | System.cmd( 170 | "git", 171 | [ 172 | "hash-object", 173 | "-w", 174 | "--", 175 | test_content_path 176 | ], 177 | cd: xgit_path 178 | ) 179 | 180 | object_id = String.trim(object_id_str) 181 | 182 | {_output, 0} = 183 | System.cmd( 184 | "git", 185 | [ 186 | "update-index", 187 | "--add", 188 | "--cacheinfo", 189 | "100644", 190 | object_id, 191 | "test" 192 | ], 193 | cd: xgit_path 194 | ) 195 | 196 | {tree_id_str, 0} = 197 | System.cmd( 198 | "git", 199 | [ 200 | "write-tree" 201 | ], 202 | cd: xgit_path 203 | ) 204 | 205 | tree_id = String.trim(tree_id_str) 206 | 207 | Map.put(context, :tree_id, tree_id) 208 | end 209 | 210 | @doc ~S""" 211 | Returns a context with an on-disk repository set up. 212 | 213 | This repository has a tree object with one file in it and an 214 | empty commit. 215 | 216 | Can optionally take a hard-wired path to use instead of the default 217 | temporary directory. Use that when you need to debug a test that is 218 | failing and you want to inspect the repo after the test completes. 219 | """ 220 | @spec setup_with_valid_parent_commit!(path: Path.t(), config_file_content: String.t()) :: %{ 221 | tmp_dir: Path.t(), 222 | config_file_path: Path.t(), 223 | xgit_path: Path.t(), 224 | xgit_repo: Storage.t(), 225 | tree_id: String.t(), 226 | parent_id: String.t() 227 | } 228 | def setup_with_valid_parent_commit!(opts \\ []) when is_list(opts) do 229 | %{xgit_path: xgit_path} = context = setup_with_valid_tree!(opts) 230 | 231 | {empty_tree_id_str, 0} = 232 | System.cmd( 233 | "git", 234 | [ 235 | "write-tree" 236 | ], 237 | cd: xgit_path 238 | ) 239 | 240 | empty_tree_id = String.trim(empty_tree_id_str) 241 | 242 | {parent_id_str, 0} = 243 | System.cmd( 244 | "git", 245 | [ 246 | "commit-tree", 247 | "-m", 248 | "empty", 249 | empty_tree_id 250 | ], 251 | cd: xgit_path, 252 | env: sample_commit_env() 253 | ) 254 | 255 | parent_id = String.trim(parent_id_str) 256 | 257 | Map.put(context, :parent_id, parent_id) 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /test/support/test/temp_dir_test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Test.TempDirTestCase do 2 | @moduledoc ~S""" 3 | (Testing only) Test case that sets up a temporary directory. 4 | """ 5 | 6 | @doc ~S""" 7 | Returns a context with a temporary directory set up. 8 | """ 9 | @spec tmp_dir!() :: %{tmp_dir: Path.t()} 10 | def tmp_dir! do 11 | Temp.track!() 12 | %{tmp_dir: Temp.mkdir!()} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/test/test_file_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Test.TestFileUtils do 2 | @moduledoc false 3 | 4 | # (Testing only) Utils to hack on files 5 | 6 | @doc ~S""" 7 | Touch a file such that it precedes any "racy git" condition. 8 | 9 | Anyone calling this function has to pinky-swear that they will not modify 10 | the file within the next three seconds. 11 | """ 12 | @spec touch_back!(path :: Path.t()) :: :ok 13 | def touch_back!(path), do: File.touch!(path, System.os_time(:second) - 3) 14 | end 15 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/xgit/config_entry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.ConfigEntryTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.ConfigEntry 5 | 6 | @valid_sections ["test", "Test", "test-24", "test-2.3"] 7 | @invalid_sections ["", 'test', "", "test 24", "test:24", "test/more-test", 42, :test, nil] 8 | 9 | @valid_subsections ["xgit", "", "xgit and more xgit", "test:24", "test/more-test", nil] 10 | @invalid_subsections ["xg\nit", "xg\0it", 'xgit', 42, :test] 11 | 12 | @valid_names ["random", "Random", "random9", "random-9"] 13 | @invalid_names ["", "9random", "random.24", "random:24", 42, nil, :test] 14 | 15 | @valid_values ["whatever", "", :remove_all, nil] 16 | @invalid_values ["what\0ever", 'whatever', 42, true, false, :test] 17 | 18 | @valid_entry %ConfigEntry{ 19 | section: "test", 20 | subsection: "xgit", 21 | name: "random", 22 | value: "whatever" 23 | } 24 | 25 | describe "valid?/1" do 26 | test "valid cases" do 27 | for section <- @valid_sections do 28 | for subsection <- @valid_subsections do 29 | for name <- @valid_names do 30 | for value <- @valid_values do 31 | entry = %ConfigEntry{ 32 | section: section, 33 | subsection: subsection, 34 | name: name, 35 | value: value 36 | } 37 | 38 | assert ConfigEntry.valid?(entry), 39 | "improperly rejected valid case #{inspect(entry, pretty: true)}" 40 | end 41 | end 42 | end 43 | end 44 | end 45 | 46 | test "not a struct" do 47 | refute ConfigEntry.valid?(%{ 48 | section: "test", 49 | subsection: "xgit", 50 | name: "random", 51 | value: "whatever" 52 | }) 53 | 54 | refute ConfigEntry.valid?("[test] random=whatever") 55 | end 56 | 57 | test "invalid section" do 58 | for section <- @invalid_sections do 59 | entry = %{@valid_entry | section: section} 60 | 61 | refute ConfigEntry.valid?(entry), 62 | "improperly accepted invalid case #{inspect(entry, pretty: true)}" 63 | end 64 | end 65 | 66 | test "invalid subsection" do 67 | for subsection <- @invalid_subsections do 68 | entry = %{@valid_entry | subsection: subsection} 69 | 70 | refute ConfigEntry.valid?(entry), 71 | "improperly accepted invalid case #{inspect(entry, pretty: true)}" 72 | end 73 | end 74 | 75 | test "invalid name" do 76 | for name <- @invalid_names do 77 | entry = %{@valid_entry | name: name} 78 | 79 | refute ConfigEntry.valid?(entry), 80 | "improperly accepted invalid case #{inspect(entry, pretty: true)}" 81 | end 82 | end 83 | 84 | test "invalid value" do 85 | for value <- @invalid_values do 86 | entry = %{@valid_entry | value: value} 87 | 88 | refute ConfigEntry.valid?(entry), 89 | "improperly accepted invalid case #{inspect(entry, pretty: true)}" 90 | end 91 | end 92 | end 93 | 94 | describe "valid_section?/1" do 95 | test "valid section names" do 96 | for section <- @valid_sections do 97 | assert ConfigEntry.valid_section?(section), 98 | "improperly rejected valid case #{inspect(section)}" 99 | end 100 | end 101 | 102 | test "invalid section names" do 103 | for section <- @invalid_sections do 104 | refute ConfigEntry.valid_section?(section), 105 | "improperly accepted invalid case #{inspect(section)}" 106 | end 107 | end 108 | end 109 | 110 | describe "valid_subsection?/1" do 111 | test "valid subsection names" do 112 | for subsection <- @valid_subsections do 113 | assert ConfigEntry.valid_subsection?(subsection), 114 | "improperly rejected valid case #{inspect(subsection)}" 115 | end 116 | end 117 | 118 | test "invalid subsection names" do 119 | for subsection <- @invalid_subsections do 120 | refute ConfigEntry.valid_subsection?(subsection), 121 | "improperly accepted invalid case #{inspect(subsection)}" 122 | end 123 | end 124 | end 125 | 126 | describe "valid_name?/1" do 127 | test "valid names" do 128 | for name <- @valid_names do 129 | assert ConfigEntry.valid_name?(name), 130 | "improperly rejected valid case #{inspect(name)}" 131 | end 132 | end 133 | 134 | test "invalid names" do 135 | for name <- @invalid_names do 136 | refute ConfigEntry.valid_name?(name), 137 | "improperly accepted invalid case #{inspect(name)}" 138 | end 139 | end 140 | end 141 | 142 | describe "valid_value?/1" do 143 | test "valid values" do 144 | for value <- @valid_values do 145 | assert ConfigEntry.valid_value?(value), 146 | "improperly rejected valid case #{inspect(value)}" 147 | end 148 | end 149 | 150 | test "invalid values" do 151 | for value <- @invalid_values do 152 | refute ConfigEntry.valid_value?(value), 153 | "improperly accepted invalid case #{inspect(value)}" 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /test/xgit/content_source_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.ContentSourceTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.ContentSource 5 | 6 | describe "implementation for list" do 7 | test "length/1" do 8 | assert ContentSource.length('1234') == 4 9 | end 10 | 11 | test "stream/1" do 12 | assert ContentSource.stream('1234') == '1234' 13 | end 14 | end 15 | 16 | describe "implementation for string" do 17 | test "length/1" do 18 | assert ContentSource.length("1234") == 4 19 | assert ContentSource.length("1ü34") == 5 20 | end 21 | 22 | test "stream/1" do 23 | assert ContentSource.stream("1234") == '1234' 24 | assert ContentSource.stream("1ü34") == [49, 195, 188, 51, 52] 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/xgit/dir_cache/entry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.DirCache.EntryTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.DirCache.Entry 5 | 6 | @valid %Entry{ 7 | name: 'hello.txt', 8 | stage: 0, 9 | object_id: "7919e8900c3af541535472aebd56d44222b7b3a3", 10 | mode: 0o100644, 11 | size: 42, 12 | ctime: 1_565_612_933, 13 | ctime_ns: 0, 14 | mtime: 1_565_612_941, 15 | mtime_ns: 0, 16 | dev: 0, 17 | ino: 0, 18 | uid: 0, 19 | gid: 0, 20 | assume_valid?: true, 21 | extended?: false, 22 | skip_worktree?: true, 23 | intent_to_add?: false 24 | } 25 | 26 | describe "valid?/1" do 27 | test "happy path: valid entry" do 28 | assert Entry.valid?(@valid) 29 | end 30 | 31 | @invalid_mods [ 32 | name: "binary, not byte list", 33 | name: '', 34 | name: '/absolute/path', 35 | stage: 4, 36 | object_id: "7919e8900c3af541535472aebd56d44222b7b3a", 37 | object_id: "7919e8900c3af541535472aebd56d44222b7b3a34", 38 | object_id: "0000000000000000000000000000000000000000", 39 | mode: 0, 40 | mode: 0o100645, 41 | size: -1, 42 | ctime: 1.45, 43 | ctime_ns: -1, 44 | mtime: "recently", 45 | mtime_ns: true, 46 | dev: 4.2, 47 | ino: true, 48 | uid: "that guy", 49 | gid: "those people", 50 | assume_valid?: "yes", 51 | extended?: "no", 52 | skip_worktree?: :maybe, 53 | intent_to_add?: :why_not? 54 | ] 55 | 56 | test "invalid entries" do 57 | Enum.each(@invalid_mods, fn {key, value} -> 58 | invalid = Map.put(@valid, key, value) 59 | 60 | refute( 61 | Entry.valid?(invalid), 62 | "incorrectly accepted entry with :#{key} set to #{inspect(value)}" 63 | ) 64 | end) 65 | end 66 | end 67 | 68 | describe "compare/2" do 69 | test "special case: nil sorts first" do 70 | assert Entry.compare(nil, @valid) == :lt 71 | end 72 | 73 | test "equality" do 74 | assert Entry.compare(@valid, @valid) == :eq 75 | end 76 | 77 | @name_gt Map.put(@valid, :name, 'later.txt') 78 | test "comparison based on name" do 79 | assert Entry.compare(@valid, @name_gt) == :lt 80 | assert Entry.compare(@name_gt, @valid) == :gt 81 | end 82 | 83 | @mode_gt Map.put(@valid, :mode, 0o100755) 84 | test "doesn't compare based on mode" do 85 | assert Entry.compare(@valid, @mode_gt) == :eq 86 | assert Entry.compare(@mode_gt, @valid) == :eq 87 | end 88 | 89 | @stage_gt Map.put(@valid, :stage, 2) 90 | test "comparison based on stage" do 91 | assert Entry.compare(@valid, @stage_gt) == :lt 92 | assert Entry.compare(@stage_gt, @valid) == :gt 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/xgit/file_content_source_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.FileContentSourceTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.ContentSource 5 | alias Xgit.FileContentSource, as: FCS 6 | alias Xgit.Test.TempDirTestCase 7 | 8 | describe "implementation for file that exists" do 9 | setup do 10 | %{tmp_dir: t} = TempDirTestCase.tmp_dir!() 11 | 12 | path = Path.join(t, "example") 13 | File.write!(path, "example") 14 | 15 | fcs = FCS.new(path) 16 | 17 | {:ok, fcs: fcs} 18 | end 19 | 20 | test "length/1", %{fcs: fcs} do 21 | assert ContentSource.length(fcs) == 7 22 | end 23 | 24 | test "stream/1", %{fcs: fcs} do 25 | assert %File.Stream{} = stream = ContentSource.stream(fcs) 26 | assert Enum.to_list(stream) == ['example'] 27 | end 28 | end 29 | 30 | describe "implementation for file that doesn't exist" do 31 | setup do 32 | %{tmp_dir: t} = TempDirTestCase.tmp_dir!() 33 | 34 | path = Path.join(t, "example") 35 | fcs = FCS.new(path) 36 | 37 | {:ok, fcs: fcs} 38 | end 39 | 40 | test "length/1", %{fcs: fcs} do 41 | assert_raise RuntimeError, "file not found", fn -> 42 | ContentSource.length(fcs) 43 | end 44 | end 45 | 46 | test "stream/1", %{fcs: fcs} do 47 | assert_raise RuntimeError, "file not found", fn -> 48 | ContentSource.stream(fcs) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/xgit/file_mode_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.FileModeTest do 2 | use ExUnit.Case, async: true 3 | 4 | use Xgit.FileMode 5 | 6 | test "tree/0" do 7 | assert FileMode.tree() == 0o040000 8 | end 9 | 10 | test "symlink/0" do 11 | assert FileMode.symlink() == 0o120000 12 | end 13 | 14 | test "regular_file/0" do 15 | assert FileMode.regular_file() == 0o100644 16 | end 17 | 18 | test "executable_file/0" do 19 | assert FileMode.executable_file() == 0o100755 20 | end 21 | 22 | test "gitlink/0" do 23 | assert FileMode.gitlink() == 0o160000 24 | end 25 | 26 | test "tree?/1" do 27 | assert FileMode.tree?(FileMode.tree()) 28 | refute FileMode.tree?(FileMode.tree() + 1) 29 | end 30 | 31 | test "symlink?/1" do 32 | assert FileMode.symlink?(FileMode.symlink()) 33 | refute FileMode.symlink?(FileMode.symlink() + 1) 34 | end 35 | 36 | test "regular_file?/1" do 37 | assert FileMode.regular_file?(FileMode.regular_file()) 38 | refute FileMode.regular_file?(FileMode.regular_file() + 1) 39 | end 40 | 41 | test "executable_file?/1" do 42 | assert FileMode.executable_file?(FileMode.executable_file()) 43 | refute FileMode.executable_file?(FileMode.executable_file() + 1) 44 | end 45 | 46 | test "gitlink?/1" do 47 | assert FileMode.gitlink?(FileMode.gitlink()) 48 | refute FileMode.gitlink?(FileMode.gitlink() + 1) 49 | end 50 | 51 | test "valid?/1" do 52 | assert FileMode.valid?(FileMode.tree()) 53 | refute FileMode.valid?(FileMode.tree() + 1) 54 | 55 | assert FileMode.valid?(FileMode.symlink()) 56 | refute FileMode.valid?(FileMode.symlink() + 1) 57 | 58 | assert FileMode.valid?(FileMode.regular_file()) 59 | refute FileMode.valid?(FileMode.regular_file() + 1) 60 | 61 | assert FileMode.valid?(FileMode.executable_file()) 62 | refute FileMode.valid?(FileMode.executable_file() + 1) 63 | 64 | assert FileMode.valid?(FileMode.gitlink()) 65 | refute FileMode.valid?(FileMode.gitlink() + 1) 66 | end 67 | 68 | test "to_short_octal/1" do 69 | assert FileMode.to_short_octal(FileMode.tree()) == '40000' 70 | assert FileMode.to_short_octal(FileMode.symlink()) == '120000' 71 | assert FileMode.to_short_octal(FileMode.regular_file()) == '100644' 72 | assert FileMode.to_short_octal(FileMode.executable_file()) == '100755' 73 | assert FileMode.to_short_octal(FileMode.gitlink()) == '160000' 74 | 75 | assert_raise FunctionClauseError, fn -> 76 | FileMode.to_short_octal(FileMode.gitlink() + 1) 77 | end 78 | end 79 | 80 | @valid_file_modes [0o040000, 0o120000, 0o100644, 0o100755, 0o160000] 81 | 82 | defp accepted_file_mode?(t) when is_file_mode(t), do: true 83 | defp accepted_file_mode?(_), do: false 84 | 85 | describe "is_file_mode/1" do 86 | test "accepts known file modes" do 87 | for t <- @valid_file_modes do 88 | assert accepted_file_mode?(t) 89 | end 90 | end 91 | 92 | test "rejects invalid values" do 93 | refute accepted_file_mode?(:mumble) 94 | refute accepted_file_mode?(0) 95 | refute accepted_file_mode?(1) 96 | refute accepted_file_mode?(0o100645) 97 | refute accepted_file_mode?("blob") 98 | refute accepted_file_mode?('blob') 99 | refute accepted_file_mode?(%{blob: true}) 100 | refute accepted_file_mode?({:blob}) 101 | refute accepted_file_mode?(fn -> :blob end) 102 | refute accepted_file_mode?(self()) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/xgit/object_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.ObjectIdTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.FileContentSource 5 | alias Xgit.ObjectId 6 | 7 | test "zero/0" do 8 | zero = ObjectId.zero() 9 | assert is_binary(zero) 10 | assert String.length(zero) == 40 11 | assert ObjectId.valid?(zero) 12 | assert String.match?(zero, ~r/^0+$/) 13 | end 14 | 15 | test "valid?/1" do 16 | assert ObjectId.valid?("1234567890abcdef12341234567890abcdef1234") 17 | refute ObjectId.valid?("1234567890abcdef1231234567890abcdef1234") 18 | refute ObjectId.valid?("1234567890abcdef123451234567890abcdef1234") 19 | refute ObjectId.valid?("1234567890abCdef12341234567890abcdef1234") 20 | refute ObjectId.valid?("1234567890abXdef12341234567890abcdef1234") 21 | 22 | refute ObjectId.valid?(nil) 23 | end 24 | 25 | test "from_binary_iodata/1" do 26 | assert ObjectId.from_binary_iodata( 27 | <<18, 52, 86, 120, 144, 171, 205, 239, 18, 52, 18, 52, 86, 120, 144, 171, 205, 239, 28 | 18, 52>> 29 | ) == "1234567890abcdef12341234567890abcdef1234" 30 | 31 | assert ObjectId.from_binary_iodata([ 32 | 18, 33 | 52, 34 | 86, 35 | 120, 36 | 144, 37 | 171, 38 | 205, 39 | 239, 40 | 18, 41 | 52, 42 | 18, 43 | 52, 44 | 86, 45 | 120, 46 | 144, 47 | 171, 48 | 205, 49 | 239, 50 | 18, 51 | 52 52 | ]) == "1234567890abcdef12341234567890abcdef1234" 53 | 54 | assert_raise FunctionClauseError, fn -> 55 | ObjectId.from_binary_iodata([ 56 | 52, 57 | 86, 58 | 120, 59 | 144, 60 | 171, 61 | 205, 62 | 239, 63 | 18, 64 | 52, 65 | 18, 66 | 52, 67 | 86, 68 | 120, 69 | 144, 70 | 171, 71 | 205, 72 | 239, 73 | 18, 74 | 52 75 | ]) 76 | 77 | # 19 bytes, not 20 78 | end 79 | end 80 | 81 | test "from_hex_charlist/1" do 82 | assert ObjectId.from_hex_charlist('1234567890abcdef12341234567890abcdef1234') == 83 | {"1234567890abcdef12341234567890abcdef1234", []} 84 | 85 | assert ObjectId.from_hex_charlist('1234567890abcdef1231234567890abcdef1234') == false 86 | 87 | assert ObjectId.from_hex_charlist('1234567890abcdef123451234567890abcdef1234') == 88 | {"1234567890abcdef123451234567890abcdef123", '4'} 89 | 90 | assert ObjectId.from_hex_charlist('1234567890abCdef12341234567890abcdef1234') == false 91 | 92 | assert ObjectId.from_hex_charlist('1234567890abXdef12341234567890abcdef1234') == false 93 | end 94 | 95 | test "to_binary_iodata/1" do 96 | assert ObjectId.to_binary_iodata("1234567890abcdef12341234567890abcdef1234") == 97 | <<18, 52, 86, 120, 144, 171, 205, 239, 18, 52, 18, 52, 86, 120, 144, 171, 205, 239, 98 | 18, 52>> 99 | end 100 | 101 | describe "calculate_id/3" do 102 | test "happy path: SHA hash with string content" do 103 | assert ObjectId.calculate_id("test content\n", :blob) == 104 | "d670460b4b4aece5915caf5c68d12f560a9fe3e4" 105 | end 106 | 107 | test "happy path: deriving SHA hash from file on disk" do 108 | Temp.track!() 109 | path = Temp.path!() 110 | 111 | content = 112 | 1..1000 113 | |> Enum.map(fn _ -> "foobar" end) 114 | |> Enum.join() 115 | 116 | File.write!(path, content) 117 | 118 | {output, 0} = System.cmd("git", ["hash-object", path]) 119 | expected_object_id = String.trim(output) 120 | 121 | fcs = FileContentSource.new(path) 122 | assert ObjectId.calculate_id(fcs, :blob) == expected_object_id 123 | end 124 | 125 | test "error: content nil" do 126 | assert_raise FunctionClauseError, fn -> 127 | ObjectId.calculate_id(nil, :blob) 128 | end 129 | end 130 | 131 | test "error: :type invalid" do 132 | assert_raise FunctionClauseError, fn -> 133 | ObjectId.calculate_id("test content\n", :bogus) 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/xgit/object_type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.ObjectTypeTest do 2 | use ExUnit.Case, async: true 3 | use Xgit.ObjectType 4 | 5 | @valid_object_types [:blob, :tree, :commit, :tag] 6 | @invalid_object_types [:mumble, 1, "blob", 'blob', %{blob: true}, {:blob}, self()] 7 | 8 | defp accepted_object_type?(t) when is_object_type(t), do: true 9 | defp accepted_object_type?(_), do: false 10 | 11 | describe "valid?/1" do 12 | test "accepts known object types" do 13 | for t <- @valid_object_types do 14 | assert ObjectType.valid?(t) 15 | end 16 | end 17 | 18 | test "rejects invalid values" do 19 | for t <- @invalid_object_types do 20 | refute ObjectType.valid?(t) 21 | end 22 | end 23 | end 24 | 25 | describe "is_object_type/1" do 26 | test "accepts known object types" do 27 | for t <- @valid_object_types do 28 | assert accepted_object_type?(t) 29 | end 30 | end 31 | 32 | test "rejects invalid values" do 33 | for t <- @invalid_object_types do 34 | refute accepted_object_type?(t) 35 | end 36 | end 37 | end 38 | 39 | describe "from_bytelist/1" do 40 | test "accepts known object types" do 41 | for t <- @valid_object_types do 42 | assert t = ObjectType.from_bytelist('#{t}') 43 | end 44 | end 45 | 46 | test "returns :error for other atoms" do 47 | assert :error = ObjectType.from_bytelist('commitx') 48 | end 49 | 50 | test "FunctionClauseError if not bytelist" do 51 | assert_raise FunctionClauseError, 52 | fn -> 53 | ObjectType.from_bytelist("commit") 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/xgit/repository/default_working_tree_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.DefaultWorkingTreeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Repository.InMemory 5 | alias Xgit.Repository.Storage 6 | alias Xgit.Repository.WorkingTree 7 | alias Xgit.Test.TempDirTestCase 8 | 9 | # We use InMemory repository because OnDisk will create its own WorkingTree 10 | # by default. 11 | 12 | describe "default_working_tree/1" do 13 | test "happy path" do 14 | {:ok, repo} = InMemory.start_link() 15 | 16 | assert Storage.default_working_tree(repo) == nil 17 | 18 | # Create a working tree and assign it. 19 | 20 | %{tmp_dir: path} = TempDirTestCase.tmp_dir!() 21 | 22 | {:ok, working_tree} = WorkingTree.start_link(repo, path) 23 | 24 | assert :ok = Storage.set_default_working_tree(repo, working_tree) 25 | assert Storage.default_working_tree(repo) == working_tree 26 | 27 | # Kids, don't try this at home. 28 | 29 | {:ok, working_tree2} = WorkingTree.start_link(repo, path) 30 | assert :error = Storage.set_default_working_tree(repo, working_tree2) 31 | assert Storage.default_working_tree(repo) == working_tree 32 | 33 | # Ensure working tree dies with repo. 34 | 35 | :ok = GenServer.stop(repo) 36 | refute Process.alive?(repo) 37 | 38 | Process.sleep(20) 39 | refute Process.alive?(working_tree) 40 | end 41 | 42 | test "rejects a process that isn't a WorkingTree" do 43 | {:ok, repo} = InMemory.start_link() 44 | {:ok, not_working_tree} = GenServer.start_link(NotValid, nil) 45 | 46 | assert :error = Storage.set_default_working_tree(repo, not_working_tree) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/xgit/repository/in_memory/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.InMemory.ConfigTest do 2 | use Xgit.Repository.Test.ConfigTest, async: true 3 | 4 | alias Xgit.Repository.InMemory 5 | 6 | setup do 7 | {:ok, repo} = InMemory.start_link() 8 | %{repo: repo} 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/xgit/repository/in_memory/get_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.InMemory.GetObjectTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Repository.InMemory 5 | alias Xgit.Repository.Storage 6 | 7 | describe "get_object/2" do 8 | # Happy paths involving existing items are tested in put_loose_object_test. 9 | 10 | test "error: no such object" do 11 | assert {:ok, repo} = InMemory.start_link() 12 | 13 | assert {:error, :not_found} = 14 | Storage.get_object(repo, "5cb5d77be2d92c7368038dac67e648a69e0a654d") 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/xgit/repository/in_memory/has_all_object_ids_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.InMemory.HasAllObjectIdsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Object 5 | alias Xgit.Repository.InMemory 6 | alias Xgit.Repository.Storage 7 | 8 | describe "has_all_object_ids?/2" do 9 | @test_content 'test content\n' 10 | @test_content_id "d670460b4b4aece5915caf5c68d12f560a9fe3e4" 11 | 12 | setup do 13 | assert {:ok, repo} = InMemory.start_link() 14 | 15 | object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} 16 | assert :ok = Storage.put_loose_object(repo, object) 17 | 18 | # Yes, the hash is wrong, but we'll ignore that for now. 19 | object = %Object{ 20 | type: :blob, 21 | content: @test_content, 22 | size: 15, 23 | id: "c1e116090ad56f172370351ab3f773eb0f1fe89e" 24 | } 25 | 26 | assert :ok = Storage.put_loose_object(repo, object) 27 | 28 | {:ok, repo: repo} 29 | end 30 | 31 | test "happy path: zero object IDs", %{repo: repo} do 32 | assert Storage.has_all_object_ids?(repo, []) 33 | end 34 | 35 | test "happy path: one object ID", %{repo: repo} do 36 | assert Storage.has_all_object_ids?(repo, [@test_content_id]) 37 | end 38 | 39 | test "happy path: two object IDs", %{repo: repo} do 40 | assert Storage.has_all_object_ids?(repo, [ 41 | @test_content_id, 42 | "c1e116090ad56f172370351ab3f773eb0f1fe89e" 43 | ]) 44 | end 45 | 46 | test "happy path: partial match", %{repo: repo} do 47 | refute Storage.has_all_object_ids?(repo, [ 48 | @test_content_id, 49 | "b9e3a9e3ea7dde01d652f899a783b75a1518564c" 50 | ]) 51 | end 52 | 53 | test "happy path: no match", %{repo: repo} do 54 | refute Storage.has_all_object_ids?(repo, [ 55 | @test_content_id, 56 | "6ee878a55ed36e2cda2c68452d2336ce3bd692d1" 57 | ]) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/xgit/repository/in_memory/put_loose_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.InMemory.PutLooseObjectTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.ContentSource 5 | alias Xgit.FileContentSource 6 | alias Xgit.Object 7 | alias Xgit.Repository.InMemory 8 | alias Xgit.Repository.Storage 9 | 10 | describe "put_loose_object/2" do 11 | # Also tests corresonding cases of get_object/2. 12 | @test_content 'test content\n' 13 | @test_content_id "d670460b4b4aece5915caf5c68d12f560a9fe3e4" 14 | 15 | test "happy path: put and get back" do 16 | assert {:ok, repo} = InMemory.start_link() 17 | 18 | object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} 19 | assert :ok = Storage.put_loose_object(repo, object) 20 | 21 | assert {:ok, ^object} = Storage.get_object(repo, @test_content_id) 22 | end 23 | 24 | test "happy path: reads file into memory" do 25 | Temp.track!() 26 | path = Temp.path!() 27 | 28 | content = 29 | 1..1000 30 | |> Enum.map(fn _ -> "foobar" end) 31 | |> Enum.join() 32 | 33 | File.write!(path, content) 34 | content_id = "b9fce9aed947fd9f5a160c18cf2983fe455f8daf" 35 | 36 | # ^ lifted from running the corresponding on-disk test. 37 | 38 | assert {:ok, repo} = InMemory.start_link() 39 | 40 | fcs = FileContentSource.new(path) 41 | object = %Object{type: :blob, content: fcs, size: ContentSource.length(fcs), id: content_id} 42 | assert :ok = Storage.put_loose_object(repo, object) 43 | 44 | content_as_binary = :binary.bin_to_list(content) 45 | content_size = byte_size(content) 46 | 47 | assert {ok, 48 | %Object{ 49 | type: :blob, 50 | content: ^content_as_binary, 51 | size: ^content_size, 52 | id: ^content_id 53 | }} = Storage.get_object(repo, content_id) 54 | 55 | assert Object.valid?(object) 56 | end 57 | 58 | test "error: object exists already" do 59 | assert {:ok, repo} = InMemory.start_link() 60 | 61 | object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} 62 | assert :ok = Storage.put_loose_object(repo, object) 63 | 64 | assert {:error, :object_exists} = Storage.put_loose_object(repo, object) 65 | 66 | assert {:ok, ^object} = Storage.get_object(repo, @test_content_id) 67 | assert Object.valid?(object) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/xgit/repository/in_memory/ref_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.InMemory.RefTest do 2 | use Xgit.Repository.Test.RefTest, async: true 3 | 4 | alias Xgit.Repository.InMemory 5 | 6 | setup do 7 | {:ok, repo} = InMemory.start_link() 8 | %{repo: repo} 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/xgit/repository/on_disk/create_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.OnDisk.CreateTest do 2 | use Bitwise 3 | use ExUnit.Case, async: true 4 | 5 | alias Xgit.Repository.OnDisk 6 | alias Xgit.Test.OnDiskRepoTestCase 7 | alias Xgit.Test.TempDirTestCase 8 | 9 | import FolderDiff 10 | 11 | describe "create/1" do 12 | test "happy path matches command-line git" do 13 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 14 | %{tmp_dir: xgit_root} = TempDirTestCase.tmp_dir!() 15 | 16 | xgit = Path.join(xgit_root, "repo") 17 | 18 | assert :ok = OnDisk.create(xgit) 19 | assert_folders_are_equal(ref, xgit) 20 | end 21 | 22 | test ".git/objects should be empty after git init in an empty repo" do 23 | # Adapted from git t0000-basic.sh 24 | %{tmp_dir: xgit_root} = TempDirTestCase.tmp_dir!() 25 | 26 | xgit = Path.join(xgit_root, "repo") 27 | assert :ok = OnDisk.create(xgit) 28 | 29 | assert {"", 0} = System.cmd("find", [".git/objects", "-type", "f"], cd: xgit) 30 | end 31 | 32 | test ".git/objects should have 3 subdirectories" do 33 | # Adapted from git t0000-basic.sh 34 | 35 | %{tmp_dir: xgit_root} = TempDirTestCase.tmp_dir!() 36 | 37 | xgit = Path.join(xgit_root, "repo") 38 | assert :ok = OnDisk.create(xgit) 39 | 40 | assert {dirs_str, 0} = System.cmd("find", [".git/objects", "-type", "d"], cd: xgit) 41 | 42 | dirs = 43 | dirs_str 44 | |> String.split("\n", trim: true) 45 | |> Enum.sort() 46 | 47 | assert dirs == [".git/objects", ".git/objects/info", ".git/objects/pack"] 48 | end 49 | 50 | defp check_config(path) do 51 | assert File.dir?(path) 52 | assert File.dir?(Path.join(path, ".git")) 53 | assert File.regular?(Path.join(path, ".git/config")) 54 | assert File.dir?(Path.join(path, ".git/refs")) 55 | 56 | refute executable?(Path.join(path, ".git/config")) 57 | 58 | # bare=$(cd "$1" && git config --bool core.bare) 59 | # worktree=$(cd "$1" && git config core.worktree) || 60 | # worktree=unset 61 | 62 | # test "$bare" = "$2" && test "$worktree" = "$3" || { 63 | # echo "expected bare=$2 worktree=$3" 64 | # echo " got bare=$bare worktree=$worktree" 65 | # return 1 66 | # } 67 | end 68 | 69 | defp executable?(path) do 70 | case File.lstat(path) do 71 | {:ok, %File.Stat{mode: mode}} -> (mode &&& 0o100) == 0o100 72 | _ -> false 73 | end 74 | end 75 | 76 | test "plain" do 77 | # Adapted from git t0001-init.sh 78 | 79 | %{tmp_dir: xgit_root} = TempDirTestCase.tmp_dir!() 80 | 81 | xgit = Path.join(xgit_root, "repo") 82 | assert :ok = OnDisk.create(xgit) 83 | 84 | check_config(xgit) 85 | end 86 | 87 | test "error: no work_dir" do 88 | assert_raise FunctionClauseError, fn -> 89 | OnDisk.create(nil) 90 | end 91 | end 92 | 93 | test "error: work dir exists already" do 94 | %{xgit_path: xgit_root} = OnDiskRepoTestCase.repo!() 95 | xgit = Path.join(xgit_root, "repo") 96 | 97 | File.mkdir_p!(xgit) 98 | assert {:error, :work_dir_must_not_exist} = OnDisk.create(xgit) 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/xgit/repository/on_disk/get_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.OnDisk.GetObjectTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.ContentSource 5 | alias Xgit.Object 6 | alias Xgit.Repository.OnDisk 7 | alias Xgit.Repository.Storage 8 | alias Xgit.Test.OnDiskRepoTestCase 9 | 10 | describe "get_object/2" do 11 | test "happy path: can read from command-line git (small file)" do 12 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 13 | 14 | Temp.track!() 15 | path = Temp.path!() 16 | File.write!(path, "test content\n") 17 | 18 | {output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: ref) 19 | test_content_id = String.trim(output) 20 | 21 | assert {:ok, repo} = OnDisk.start_link(work_dir: ref) 22 | 23 | assert {:ok, 24 | %Object{type: :blob, content: test_content, size: 13, id: ^test_content_id} = object} = 25 | Storage.get_object(repo, test_content_id) 26 | 27 | rendered_content = 28 | test_content 29 | |> ContentSource.stream() 30 | |> Enum.to_list() 31 | 32 | assert rendered_content == 'test content\n' 33 | assert ContentSource.length(test_content) == 13 34 | end 35 | 36 | test "happy path: can read from command-line git (large file)" do 37 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 38 | 39 | Temp.track!() 40 | path = Temp.path!() 41 | 42 | content = 43 | 1..1000 44 | |> Enum.map(fn _ -> "foobar" end) 45 | |> Enum.join() 46 | 47 | File.write!(path, content) 48 | 49 | {output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: ref) 50 | content_id = String.trim(output) 51 | 52 | assert {:ok, repo} = OnDisk.start_link(work_dir: ref) 53 | 54 | assert {:ok, 55 | %Object{type: :blob, content: test_content, size: 6000, id: ^content_id} = object} = 56 | Storage.get_object(repo, content_id) 57 | 58 | assert Object.valid?(object) 59 | 60 | test_content_str = 61 | test_content 62 | |> ContentSource.stream() 63 | |> Enum.to_list() 64 | |> to_string() 65 | 66 | assert test_content_str == content 67 | end 68 | 69 | test "error: no such file" do 70 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 71 | 72 | assert {:error, :not_found} = 73 | Storage.get_object(repo, "5cb5d77be2d92c7368038dac67e648a69e0a654d") 74 | end 75 | 76 | test "error: invalid object (not ZIP compressed)" do 77 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 78 | assert_zip_data_is_invalid(xgit, "blob ") 79 | end 80 | 81 | test "error: invalid object (ZIP compressed, but incomplete)" do 82 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 83 | 84 | # "blob " 85 | assert_zip_data_is_invalid(xgit, <<120, 1, 75, 202, 201, 79, 82, 0, 0, 5, 208, 1, 192>>) 86 | end 87 | 88 | test "error: invalid object (ZIP compressed object type without length)" do 89 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 90 | 91 | # "blob" 92 | assert_zip_data_is_invalid( 93 | xgit, 94 | <<120, 156, 75, 202, 201, 79, 2, 0, 4, 16, 1, 160>> 95 | ) 96 | end 97 | 98 | test "error: invalid object (ZIP compressed, but invalid object type)" do 99 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 100 | 101 | # "blog 13\0" 102 | assert_zip_data_is_invalid( 103 | xgit, 104 | <<120, 1, 75, 202, 201, 79, 87, 48, 52, 102, 0, 0, 12, 34, 2, 41>> 105 | ) 106 | end 107 | 108 | test "error: invalid object (ZIP compressed, but invalid object type 2)" do 109 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 110 | 111 | # "blobx 1234\0" 112 | assert_zip_data_is_invalid( 113 | xgit, 114 | <<120, 1, 75, 202, 201, 79, 170, 80, 48, 52, 50, 54, 97, 0, 0, 22, 54, 3, 2>> 115 | ) 116 | end 117 | 118 | test "error: invalid object (ZIP compressed, but invalid length)" do 119 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 120 | 121 | # "blob 13 \0" (extra space) 122 | assert_zip_data_is_invalid( 123 | xgit, 124 | <<120, 1, 75, 202, 201, 79, 82, 48, 52, 86, 96, 0, 0, 14, 109, 2, 68>> 125 | ) 126 | end 127 | 128 | test "error: invalid object (ZIP compressed, but invalid length 2)" do 129 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 130 | 131 | # "blob 12x34\0" 132 | assert_zip_data_is_invalid( 133 | xgit, 134 | <<120, 1, 75, 202, 201, 79, 82, 48, 52, 170, 48, 54, 97, 0, 0, 21, 81, 3, 2>> 135 | ) 136 | end 137 | 138 | test "error: invalid object (ZIP compressed, but no length)" do 139 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 140 | 141 | # "blob \0" (space, but no length) 142 | assert_zip_data_is_invalid(xgit, <<120, 1, 75, 202, 201, 79, 82, 96, 0, 0, 7, 144, 1, 192>>) 143 | end 144 | end 145 | 146 | defp assert_zip_data_is_invalid(xgit, data) do 147 | path = Path.join([xgit, ".git", "objects", "5c"]) 148 | File.mkdir_p!(path) 149 | 150 | File.write!(Path.join(path, "b5d77be2d92c7368038dac67e648a69e0a654d"), data) 151 | 152 | assert {:ok, repo} = OnDisk.start_link(work_dir: xgit) 153 | 154 | assert {:error, :invalid_object} = 155 | Storage.get_object(repo, "5cb5d77be2d92c7368038dac67e648a69e0a654d") 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /test/xgit/repository/on_disk/has_all_object_ids_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.OnDisk.HasAllObjectIdsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Object 5 | alias Xgit.Repository.Storage 6 | alias Xgit.Test.OnDiskRepoTestCase 7 | 8 | describe "has_all_object_ids?/2" do 9 | @test_content 'test content\n' 10 | @test_content_id "d670460b4b4aece5915caf5c68d12f560a9fe3e4" 11 | 12 | setup do 13 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 14 | 15 | object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} 16 | assert :ok = Storage.put_loose_object(repo, object) 17 | 18 | # Yes, the hash is wrong, but we'll ignore that for now. 19 | object = %Object{ 20 | type: :blob, 21 | content: @test_content, 22 | size: 15, 23 | id: "c1e116090ad56f172370351ab3f773eb0f1fe89e" 24 | } 25 | 26 | assert :ok = Storage.put_loose_object(repo, object) 27 | 28 | {:ok, repo: repo} 29 | end 30 | 31 | test "happy path: zero object IDs", %{repo: repo} do 32 | assert true == Storage.has_all_object_ids?(repo, []) 33 | end 34 | 35 | test "happy path: one object ID", %{repo: repo} do 36 | assert true == Storage.has_all_object_ids?(repo, [@test_content_id]) 37 | end 38 | 39 | test "happy path: two object IDs", %{repo: repo} do 40 | assert true == 41 | Storage.has_all_object_ids?(repo, [ 42 | @test_content_id, 43 | "c1e116090ad56f172370351ab3f773eb0f1fe89e" 44 | ]) 45 | end 46 | 47 | test "happy path: partial match", %{repo: repo} do 48 | assert false == 49 | Storage.has_all_object_ids?(repo, [ 50 | @test_content_id, 51 | "b9e3a9e3ea7dde01d652f899a783b75a1518564c" 52 | ]) 53 | end 54 | 55 | test "happy path: no match", %{repo: repo} do 56 | assert false == 57 | Storage.has_all_object_ids?(repo, [ 58 | @test_content_id, 59 | "6ee878a55ed36e2cda2c68452d2336ce3bd692d1" 60 | ]) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/xgit/repository/on_disk/put_loose_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.OnDisk.PutLooseObjectTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.ContentSource 5 | alias Xgit.FileContentSource 6 | alias Xgit.Object 7 | alias Xgit.Repository.Storage 8 | alias Xgit.Test.OnDiskRepoTestCase 9 | 10 | import FolderDiff 11 | 12 | describe "put_loose_object/2" do 13 | @test_content 'test content\n' 14 | @test_content_id "d670460b4b4aece5915caf5c68d12f560a9fe3e4" 15 | 16 | test "happy path matches command-line git (small file)" do 17 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 18 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 19 | 20 | Temp.track!() 21 | path = Temp.path!() 22 | File.write!(path, "test content\n") 23 | 24 | {output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: ref) 25 | assert String.trim(output) == @test_content_id 26 | 27 | object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} 28 | assert :ok = Storage.put_loose_object(repo, object) 29 | 30 | assert_folders_are_equal(ref, xgit) 31 | 32 | assert {:ok, 33 | %Object{type: :blob, content: content_read_back, size: 13, id: @test_content_id} = 34 | object2} = Storage.get_object(repo, @test_content_id) 35 | 36 | assert Object.valid?(object2) 37 | 38 | content2 = 39 | content_read_back 40 | |> ContentSource.stream() 41 | |> Enum.to_list() 42 | 43 | assert content2 == @test_content 44 | end 45 | 46 | test "happy path matches command-line git (large file)" do 47 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 48 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 49 | 50 | Temp.track!() 51 | path = Temp.path!() 52 | 53 | content = 54 | 1..1000 55 | |> Enum.map(fn _ -> "foobar" end) 56 | |> Enum.join() 57 | 58 | File.write!(path, content) 59 | 60 | {output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: ref) 61 | content_id = String.trim(output) 62 | 63 | fcs = FileContentSource.new(path) 64 | object = %Object{type: :blob, content: fcs, size: ContentSource.length(fcs), id: content_id} 65 | assert :ok = Storage.put_loose_object(repo, object) 66 | 67 | assert_folders_are_equal(ref, xgit) 68 | end 69 | 70 | test "error: can't create objects dir" do 71 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 72 | 73 | objects_dir = Path.join([xgit, ".git", "objects", String.slice(@test_content_id, 0, 2)]) 74 | File.mkdir_p!(Path.join([xgit, ".git", "objects"])) 75 | File.write!(objects_dir, "sand in the gears") 76 | 77 | object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} 78 | assert {:error, :cant_create_file} = Storage.put_loose_object(repo, object) 79 | end 80 | 81 | test "error: object exists already" do 82 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 83 | 84 | objects_dir = Path.join([xgit, ".git", "objects", String.slice(@test_content_id, 0, 2)]) 85 | File.mkdir_p!(objects_dir) 86 | 87 | File.write!( 88 | Path.join(objects_dir, String.slice(@test_content_id, 2, 38)), 89 | "sand in the gears" 90 | ) 91 | 92 | object = %Object{type: :blob, content: @test_content, size: 13, id: @test_content_id} 93 | assert {:error, :object_exists} = Storage.put_loose_object(repo, object) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/xgit/repository/on_disk_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.OnDiskTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Repository 5 | alias Xgit.Repository.OnDisk 6 | alias Xgit.Repository.Storage 7 | alias Xgit.Repository.WorkingTree 8 | alias Xgit.Test.OnDiskRepoTestCase 9 | alias Xgit.Test.TempDirTestCase 10 | 11 | import ExUnit.CaptureLog 12 | 13 | describe "start_link/1" do 14 | test "happy path: starts and is valid and has a working directory attached" do 15 | %{tmp_dir: xgit_root} = TempDirTestCase.tmp_dir!() 16 | 17 | xgit = Path.join(xgit_root, "tmp") 18 | 19 | assert :ok = OnDisk.create(xgit) 20 | assert {:ok, repo} = OnDisk.start_link(work_dir: xgit) 21 | 22 | assert is_pid(repo) 23 | assert Repository.valid?(repo) 24 | assert Storage.valid?(repo) 25 | Storage.assert_valid(repo) 26 | 27 | assert working_tree = Storage.default_working_tree(repo) 28 | assert is_pid(working_tree) 29 | assert WorkingTree.valid?(working_tree) 30 | end 31 | 32 | test "error: can't launch because bad config file" do 33 | %{tmp_dir: xgit_root} = TempDirTestCase.tmp_dir!() 34 | 35 | xgit = Path.join(xgit_root, "tmp") 36 | 37 | assert :ok = OnDisk.create(xgit) 38 | 39 | config_path = Path.join([xgit_root, "tmp", ".git", "config"]) 40 | File.write!(config_path, "[bogus] config var name with spaces = bogus") 41 | 42 | assert {:error, _} = OnDisk.start_link(work_dir: xgit) 43 | end 44 | 45 | test "handles unknown message" do 46 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 47 | 48 | assert capture_log(fn -> 49 | assert {:error, :unknown_message} = GenServer.call(repo, :random_unknown_message) 50 | end) =~ "Repository received unrecognized call :random_unknown_message" 51 | end 52 | 53 | test "error: missing work_dir" do 54 | Process.flag(:trap_exit, true) 55 | assert {:error, :missing_arguments} = OnDisk.start_link([]) 56 | end 57 | 58 | test "error: work_dir doesn't exist" do 59 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 60 | 61 | Process.flag(:trap_exit, true) 62 | 63 | assert {:error, :work_dir_doesnt_exist} = 64 | OnDisk.start_link(work_dir: Path.join(xgit, "random")) 65 | end 66 | 67 | test "error: git_dir doesn't exist" do 68 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 69 | 70 | Process.flag(:trap_exit, true) 71 | 72 | temp_dir = Path.join(xgit, "blah") 73 | File.mkdir_p!(temp_dir) 74 | 75 | assert {:error, :git_dir_doesnt_exist} = OnDisk.start_link(work_dir: temp_dir) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/xgit/repository/plumbing/cat_file_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.Plumbing.CatFileTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.ContentSource 5 | alias Xgit.Repository.InvalidRepositoryError 6 | alias Xgit.Repository.Plumbing 7 | alias Xgit.Test.OnDiskRepoTestCase 8 | 9 | describe "run/2" do 10 | test "happy path: can read from command-line git (small file)" do 11 | %{xgit_path: ref, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 12 | 13 | Temp.track!() 14 | path = Temp.path!() 15 | File.write!(path, "test content\n") 16 | 17 | {output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: ref) 18 | test_content_id = String.trim(output) 19 | 20 | assert {:ok, %{type: :blob, size: 13, content: test_content} = object} = 21 | Plumbing.cat_file(repo, test_content_id) 22 | 23 | rendered_content = 24 | test_content 25 | |> ContentSource.stream() 26 | |> Enum.to_list() 27 | 28 | assert rendered_content == 'test content\n' 29 | end 30 | 31 | test "happy path: can read back from Xgit-written loose object" do 32 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 33 | 34 | {:ok, test_content_id} = Plumbing.hash_object("test content\n", repo: repo, write?: true) 35 | 36 | assert {:ok, %{type: :blob, size: 13, content: test_content} = object} = 37 | Plumbing.cat_file(repo, test_content_id) 38 | 39 | rendered_content = 40 | test_content 41 | |> ContentSource.stream() 42 | |> Enum.to_list() 43 | 44 | assert rendered_content == 'test content\n' 45 | end 46 | 47 | test "error: not_found" do 48 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 49 | 50 | assert {:error, :not_found} = 51 | Plumbing.cat_file(repo, "6c22d81cc51c6518e4625a9fe26725af52403b4f") 52 | end 53 | 54 | test "error: invalid_object" do 55 | %{xgit_path: xgit_path, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 56 | 57 | path = Path.join([xgit_path, ".git", "objects", "5c"]) 58 | File.mkdir_p!(path) 59 | 60 | File.write!( 61 | Path.join(path, "b5d77be2d92c7368038dac67e648a69e0a654d"), 62 | <<120, 1, 75, 202, 201, 79, 170, 80, 48, 52, 50, 54, 97, 0, 0, 22, 54, 3, 2>> 63 | ) 64 | 65 | assert {:error, :invalid_object} = 66 | Plumbing.cat_file(repo, "5cb5d77be2d92c7368038dac67e648a69e0a654d") 67 | end 68 | 69 | test "error: repository invalid (not PID)" do 70 | assert_raise FunctionClauseError, fn -> 71 | Plumbing.cat_file("xgit repo", "18a4a651653d7caebd3af9c05b0dc7ffa2cd0ae0") 72 | end 73 | end 74 | 75 | test "error: repository invalid (PID, but not repo)" do 76 | {:ok, not_repo} = GenServer.start_link(NotValid, nil) 77 | 78 | assert_raise InvalidRepositoryError, fn -> 79 | Plumbing.cat_file(not_repo, "18a4a651653d7caebd3af9c05b0dc7ffa2cd0ae0") 80 | end 81 | end 82 | 83 | test "error: object_id invalid (not binary)" do 84 | %{xgit_repo: xgit_repo} = OnDiskRepoTestCase.repo!() 85 | 86 | assert_raise FunctionClauseError, fn -> 87 | Plumbing.cat_file(xgit_repo, 0x18A4) 88 | end 89 | end 90 | 91 | test "error: object_id invalid (binary, but not valid object ID)" do 92 | %{xgit_repo: xgit_repo} = OnDiskRepoTestCase.repo!() 93 | 94 | assert {:error, :invalid_object_id} = 95 | Plumbing.cat_file(xgit_repo, "some random ID that isn't valid") 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/xgit/repository/plumbing/cat_file_tree_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.Plumbing.CatFileTreeTest do 2 | use Xgit.Test.OnDiskRepoTestCase, async: true 3 | 4 | alias Xgit.Repository.InMemory 5 | alias Xgit.Repository.InvalidRepositoryError 6 | alias Xgit.Repository.Plumbing 7 | alias Xgit.Tree 8 | 9 | describe "cat_file_tree/2" do 10 | defp write_git_tree_and_read_xgit_tree(xgit_repo, xgit_path) do 11 | {output, 0} = System.cmd("git", ["write-tree", "--missing-ok"], cd: xgit_path) 12 | tree_id = String.trim(output) 13 | 14 | assert {:ok, %Tree{} = tree} = Plumbing.cat_file_tree(xgit_repo, tree_id) 15 | tree 16 | end 17 | 18 | test "happy path: can read from command-line git (no files)", %{ 19 | xgit_repo: xgit_repo, 20 | xgit_path: xgit_path 21 | } do 22 | assert %Tree{entries: []} = write_git_tree_and_read_xgit_tree(xgit_repo, xgit_path) 23 | end 24 | 25 | test "happy path: can read from command-line git (one file)", %{ 26 | xgit_repo: xgit_repo, 27 | xgit_path: xgit_path 28 | } do 29 | {_output, 0} = 30 | System.cmd( 31 | "git", 32 | [ 33 | "update-index", 34 | "--add", 35 | "--cacheinfo", 36 | "100644", 37 | "7919e8900c3af541535472aebd56d44222b7b3a3", 38 | "hello.txt" 39 | ], 40 | cd: xgit_path 41 | ) 42 | 43 | assert %Tree{ 44 | entries: [ 45 | %Tree.Entry{ 46 | name: 'hello.txt', 47 | mode: 0o100644, 48 | object_id: "7919e8900c3af541535472aebd56d44222b7b3a3" 49 | } 50 | ] 51 | } = write_git_tree_and_read_xgit_tree(xgit_repo, xgit_path) 52 | end 53 | 54 | test "tree with multiple entries", %{xgit_repo: xgit_repo, xgit_path: xgit_path} do 55 | {_output, 0} = 56 | System.cmd( 57 | "git", 58 | [ 59 | "update-index", 60 | "--add", 61 | "--cacheinfo", 62 | "100644", 63 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 64 | "hello.txt" 65 | ], 66 | cd: xgit_path 67 | ) 68 | 69 | {_output, 0} = 70 | System.cmd( 71 | "git", 72 | [ 73 | "update-index", 74 | "--add", 75 | "--cacheinfo", 76 | "100755", 77 | "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 78 | "test_content.txt" 79 | ], 80 | cd: xgit_path 81 | ) 82 | 83 | assert write_git_tree_and_read_xgit_tree(xgit_repo, xgit_path) == %Tree{ 84 | entries: [ 85 | %Tree.Entry{ 86 | name: 'hello.txt', 87 | object_id: "18832d35117ef2f013c4009f5b2128dfaeff354f", 88 | mode: 0o100644 89 | }, 90 | %Tree.Entry{ 91 | name: 'test_content.txt', 92 | object_id: "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 93 | mode: 0o100755 94 | } 95 | ] 96 | } 97 | end 98 | 99 | test "error: not_found" do 100 | {:ok, repo} = InMemory.start_link() 101 | 102 | assert {:error, :not_found} = 103 | Plumbing.cat_file_tree(repo, "6c22d81cc51c6518e4625a9fe26725af52403b4f") 104 | end 105 | 106 | test "error: invalid_object", %{xgit_repo: xgit_repo, xgit_path: xgit_path} do 107 | path = Path.join([xgit_path, ".git", "objects", "5c"]) 108 | File.mkdir_p!(path) 109 | 110 | File.write!( 111 | Path.join(path, "b5d77be2d92c7368038dac67e648a69e0a654d"), 112 | <<120, 1, 75, 202, 201, 79, 170, 80, 48, 52, 50, 54, 97, 0, 0, 22, 54, 3, 2>> 113 | ) 114 | 115 | assert {:error, :invalid_object} = 116 | Plumbing.cat_file_tree(xgit_repo, "5cb5d77be2d92c7368038dac67e648a69e0a654d") 117 | end 118 | 119 | test "error: not_a_tree", %{xgit_repo: xgit_repo, xgit_path: xgit_path} do 120 | Temp.track!() 121 | path = Temp.path!() 122 | 123 | File.write!(path, "test content\n") 124 | 125 | {output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: xgit_path) 126 | object_id = String.trim(output) 127 | 128 | assert {:error, :not_a_tree} = Plumbing.cat_file_tree(xgit_repo, object_id) 129 | end 130 | 131 | test "error: repository invalid (not PID)" do 132 | assert_raise FunctionClauseError, fn -> 133 | Plumbing.cat_file_tree("xgit repo", "18a4a651653d7caebd3af9c05b0dc7ffa2cd0ae0") 134 | end 135 | end 136 | 137 | test "error: repository invalid (PID, but not repo)" do 138 | {:ok, not_repo} = GenServer.start_link(NotValid, nil) 139 | 140 | assert_raise InvalidRepositoryError, fn -> 141 | Plumbing.cat_file_tree(not_repo, "18a4a651653d7caebd3af9c05b0dc7ffa2cd0ae0") 142 | end 143 | end 144 | 145 | test "error: object_id invalid (not binary)" do 146 | {:ok, repo} = InMemory.start_link() 147 | 148 | assert_raise FunctionClauseError, fn -> 149 | Plumbing.cat_file_tree(repo, 0x18A4) 150 | end 151 | end 152 | 153 | test "error: object_id invalid (binary, but not valid object ID)" do 154 | {:ok, repo} = InMemory.start_link() 155 | 156 | assert {:error, :invalid_object_id} = 157 | Plumbing.cat_file_tree(repo, "some random ID that isn't valid") 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /test/xgit/repository/plumbing/delete_symbolic_ref_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.Plumbing.DeleteSymbolicRefTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Repository.InvalidRepositoryError 5 | alias Xgit.Repository.Plumbing 6 | alias Xgit.Test.OnDiskRepoTestCase 7 | 8 | import FolderDiff 9 | 10 | describe "delete_symbolic_ref/2" do 11 | test "happy path: target ref does not exist" do 12 | %{xgit_path: path, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 13 | 14 | assert :ok = Plumbing.delete_symbolic_ref(repo, "HEAD") 15 | 16 | refute File.exists?(Path.join(path, ".git/HEAD")) 17 | end 18 | 19 | test "error: posix error (dir where file should be)" do 20 | %{xgit_repo: repo, xgit_path: xgit_path} = OnDiskRepoTestCase.repo!() 21 | 22 | File.mkdir_p!(Path.join(xgit_path, ".git/refs/heads/whatever")) 23 | 24 | assert {:error, :cant_delete_file} = 25 | Plumbing.delete_symbolic_ref(repo, "refs/heads/whatever") 26 | end 27 | 28 | test "error: posix error (file where dir should be)" do 29 | %{xgit_repo: repo, xgit_path: xgit_path} = OnDiskRepoTestCase.repo!() 30 | 31 | File.write!(Path.join(xgit_path, ".git/refs/heads/sub"), "oops, not a directory") 32 | 33 | assert {:error, :cant_delete_file} = 34 | Plumbing.delete_symbolic_ref(repo, "refs/heads/sub/master") 35 | end 36 | 37 | test "matches command-line output" do 38 | %{xgit_path: xgit_path, xgit_repo: xgit_repo} = OnDiskRepoTestCase.repo!() 39 | %{xgit_path: ref_path} = OnDiskRepoTestCase.repo!() 40 | 41 | :ok = Plumbing.put_symbolic_ref(xgit_repo, "refs/heads/source", "refs/heads/other") 42 | 43 | {_, 0} = 44 | System.cmd("git", ["symbolic-ref", "refs/heads/source", "refs/heads/other"], cd: ref_path) 45 | 46 | assert_folders_are_equal(ref_path, xgit_path) 47 | 48 | {_, 0} = System.cmd("git", ["symbolic-ref", "--delete", "refs/heads/source"], cd: ref_path) 49 | 50 | assert :ok = Plumbing.delete_symbolic_ref(xgit_repo, "refs/heads/source") 51 | 52 | assert_folders_are_equal(ref_path, xgit_path) 53 | end 54 | 55 | test "error: repository invalid (not PID)" do 56 | assert_raise FunctionClauseError, fn -> 57 | Plumbing.delete_symbolic_ref("xgit repo", "HEAD") 58 | end 59 | end 60 | 61 | test "error: repository invalid (PID, but not repo)" do 62 | {:ok, not_repo} = GenServer.start_link(NotValid, nil) 63 | 64 | assert_raise InvalidRepositoryError, fn -> 65 | Plumbing.delete_symbolic_ref(not_repo, "HEAD") 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/xgit/repository/plumbing/get_symbolic_ref_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.Plumbing.GetSymbolicRefTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Repository.InvalidRepositoryError 5 | alias Xgit.Repository.Plumbing 6 | alias Xgit.Test.OnDiskRepoTestCase 7 | 8 | describe "get_symbolic_ref/2" do 9 | test "happy path: default HEAD branch points to master" do 10 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 11 | assert {:ok, "refs/heads/master"} = Plumbing.get_symbolic_ref(repo, "HEAD") 12 | end 13 | 14 | test "happy path: HEAD branch points to non-existent branch" do 15 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 16 | 17 | assert :ok = Plumbing.put_symbolic_ref(repo, "HEAD", "refs/heads/nope") 18 | 19 | assert {:ok, "refs/heads/nope"} = Plumbing.get_symbolic_ref(repo, "HEAD") 20 | end 21 | 22 | test "error: not a symbolic references" do 23 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 24 | 25 | {:ok, commit_id_master} = 26 | Plumbing.hash_object('shhh... not really a commit', 27 | repo: repo, 28 | type: :commit, 29 | validate?: false, 30 | write?: true 31 | ) 32 | 33 | assert :ok = Plumbing.update_ref(repo, "HEAD", commit_id_master) 34 | 35 | assert {:error, :not_symbolic_ref} = Plumbing.get_symbolic_ref(repo, "refs/heads/master") 36 | end 37 | 38 | test "error: posix error (dir where file should be)" do 39 | %{xgit_repo: repo, xgit_path: xgit_path} = OnDiskRepoTestCase.repo!() 40 | 41 | File.mkdir_p!(Path.join(xgit_path, ".git/refs/heads/whatever")) 42 | 43 | assert {:error, :eisdir} = Plumbing.get_symbolic_ref(repo, "refs/heads/whatever") 44 | end 45 | 46 | test "error: posix error (file where dir should be)" do 47 | %{xgit_repo: repo, xgit_path: xgit_path} = OnDiskRepoTestCase.repo!() 48 | 49 | File.write!(Path.join(xgit_path, ".git/refs/heads/sub"), "oops, not a directory") 50 | 51 | assert {:error, :not_found} = Plumbing.get_symbolic_ref(repo, "refs/heads/sub/master") 52 | end 53 | 54 | test "error: repository invalid (not PID)" do 55 | assert_raise FunctionClauseError, fn -> 56 | Plumbing.get_symbolic_ref("xgit repo", "HEAD") 57 | end 58 | end 59 | 60 | test "error: repository invalid (PID, but not repo)" do 61 | {:ok, not_repo} = GenServer.start_link(NotValid, nil) 62 | 63 | assert_raise InvalidRepositoryError, fn -> 64 | Plumbing.get_symbolic_ref(not_repo, "HEAD") 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/xgit/repository/plumbing/hash_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.Plumbing.HashObjectTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.FileContentSource 5 | alias Xgit.Repository.Plumbing 6 | alias Xgit.Test.OnDiskRepoTestCase 7 | 8 | import FolderDiff 9 | 10 | describe "run/2" do 11 | test "happy path: deriving SHA hash with no repo" do 12 | # $ echo 'test content' | git hash-object --stdin 13 | # d670460b4b4aece5915caf5c68d12f560a9fe3e4 14 | 15 | assert {:ok, "d670460b4b4aece5915caf5c68d12f560a9fe3e4"} = 16 | Plumbing.hash_object("test content\n") 17 | end 18 | 19 | test "happy path: deriving SHA hash (large file on disk) with no repo" do 20 | Temp.track!() 21 | path = Temp.path!() 22 | 23 | content = 24 | 1..1000 25 | |> Enum.map(fn _ -> "foobar" end) 26 | |> Enum.join() 27 | 28 | File.write!(path, content) 29 | 30 | {output, 0} = System.cmd("git", ["hash-object", path]) 31 | expected_object_id = String.trim(output) 32 | 33 | assert {:ok, ^expected_object_id} = 34 | path 35 | |> FileContentSource.new() 36 | |> Plumbing.hash_object() 37 | end 38 | 39 | test "happy path: write to repo matches command-line git (small file)" do 40 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 41 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 42 | 43 | Temp.track!() 44 | path = Temp.path!() 45 | File.write!(path, "test content\n") 46 | 47 | {_output, 0} = System.cmd("git", ["hash-object", "-w", path], cd: ref) 48 | 49 | assert {:ok, "d670460b4b4aece5915caf5c68d12f560a9fe3e4"} = 50 | Plumbing.hash_object("test content\n", repo: repo, write?: true) 51 | 52 | assert File.exists?( 53 | Path.join([xgit, ".git", "objects", "d6", "70460b4b4aece5915caf5c68d12f560a9fe3e4"]) 54 | ) 55 | 56 | assert_folders_are_equal(ref, xgit) 57 | end 58 | 59 | test "happy path: repo, but don't write, matches command-line git (small file)" do 60 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 61 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 62 | 63 | Temp.track!() 64 | path = Temp.path!() 65 | File.write!(path, "test content\n") 66 | 67 | {_output, 0} = System.cmd("git", ["hash-object", path], cd: ref) 68 | 69 | assert {:ok, "d670460b4b4aece5915caf5c68d12f560a9fe3e4"} = 70 | Plumbing.hash_object("test content\n", repo: repo, write?: false) 71 | 72 | refute File.exists?( 73 | Path.join([xgit, ".git", "objects", "d6", "70460b4b4aece5915caf5c68d12f560a9fe3e4"]) 74 | ) 75 | 76 | assert_folders_are_equal(ref, xgit) 77 | end 78 | 79 | test "happy path: validate content (content is valid)" do 80 | Temp.track!() 81 | path = Temp.path!() 82 | 83 | content = ~C""" 84 | tree be9bfa841874ccc9f2ef7c48d0c76226f89b7189 85 | author A. U. Thor 1 +0000 86 | committer A. U. Thor 1 +0000 87 | """ 88 | 89 | File.write(path, content) 90 | 91 | {output, 0} = System.cmd("git", ["hash-object", "-t", "commit", path]) 92 | expected_object_id = String.trim(output) 93 | 94 | assert {:ok, ^expected_object_id} = 95 | path 96 | |> FileContentSource.new() 97 | |> Plumbing.hash_object(type: :commit) 98 | end 99 | 100 | test "validate?: false skips validation" do 101 | Temp.track!() 102 | path = Temp.path!() 103 | 104 | content = ~C""" 105 | trie be9bfa841874ccc9f2ef7c48d0c76226f89b7189 106 | author A. U. Thor 1 +0000 107 | committer A. U. Thor 1 +0000 108 | """ 109 | 110 | File.write(path, content) 111 | 112 | {output, 0} = System.cmd("git", ["hash-object", "--literally", "-t", "commit", path]) 113 | expected_object_id = String.trim(output) 114 | 115 | assert {:ok, ^expected_object_id} = 116 | path 117 | |> FileContentSource.new() 118 | |> Plumbing.hash_object(type: :commit, validate?: false) 119 | end 120 | 121 | test "error: validate content (content is invalid)" do 122 | content = ~C""" 123 | trie be9bfa841874ccc9f2ef7c48d0c76226f89b7189 124 | author A. U. Thor 1 +0000 125 | committer A. U. Thor 1 +0000 126 | """ 127 | 128 | assert {:error, :no_tree_header} = Plumbing.hash_object(content, type: :commit) 129 | end 130 | 131 | test "error: can't write to disk" do 132 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 133 | 134 | Temp.track!() 135 | path = Temp.path!() 136 | File.write!(path, "test content\n") 137 | 138 | [xgit, ".git", "objects", "d6", "70460b4b4aece5915caf5c68d12f560a9fe3e4"] 139 | |> Path.join() 140 | |> File.mkdir_p!() 141 | 142 | assert {:error, :object_exists} = 143 | Plumbing.hash_object("test content\n", repo: repo, write?: true) 144 | end 145 | 146 | test "error: content nil" do 147 | assert_raise FunctionClauseError, fn -> 148 | Plumbing.hash_object(nil) 149 | end 150 | end 151 | 152 | test "error: :type invalid" do 153 | assert_raise ArgumentError, 154 | "Xgit.Repository.Plumbing.hash_object/2: type :bogus is invalid", 155 | fn -> 156 | Plumbing.hash_object("test content\n", type: :bogus) 157 | end 158 | end 159 | 160 | test "error: :validate? invalid" do 161 | assert_raise ArgumentError, 162 | ~s(Xgit.Repository.Plumbing.hash_object/2: validate? "yes" is invalid), 163 | fn -> 164 | Plumbing.hash_object("test content\n", validate?: "yes") 165 | end 166 | end 167 | 168 | test "error: :repo invalid" do 169 | assert_raise ArgumentError, 170 | ~s(Xgit.Repository.Plumbing.hash_object/2: repo "/path/to/repo" is invalid), 171 | fn -> 172 | Plumbing.hash_object("test content\n", repo: "/path/to/repo") 173 | end 174 | end 175 | 176 | test "error: :write? invalid" do 177 | assert_raise ArgumentError, 178 | ~s(Xgit.Repository.Plumbing.hash_object/2: write? "yes" is invalid), 179 | fn -> 180 | Plumbing.hash_object("test content\n", write?: "yes") 181 | end 182 | end 183 | 184 | test "error: :write? without repo" do 185 | assert_raise ArgumentError, 186 | ~s(Xgit.Repository.Plumbing.hash_object/2: write?: true requires a repo to be specified), 187 | fn -> 188 | Plumbing.hash_object("test content\n", write?: true) 189 | end 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /test/xgit/repository/plumbing/ls_files_stage_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.Plumbing.LsFilesStageTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.DirCache.Entry, as: DirCacheEntry 5 | alias Xgit.Repository.InMemory 6 | alias Xgit.Repository.InvalidRepositoryError 7 | alias Xgit.Repository.OnDisk 8 | alias Xgit.Repository.Plumbing 9 | alias Xgit.Repository.Storage 10 | alias Xgit.Repository.WorkingTree 11 | alias Xgit.Test.OnDiskRepoTestCase 12 | alias Xgit.Test.TempDirTestCase 13 | 14 | describe "ls_files_stage/1" do 15 | test "happy path: no index file" do 16 | {:ok, repo} = InMemory.start_link() 17 | 18 | %{tmp_dir: path} = TempDirTestCase.tmp_dir!() 19 | 20 | {:ok, working_tree} = WorkingTree.start_link(repo, path) 21 | :ok = Storage.set_default_working_tree(repo, working_tree) 22 | 23 | assert {:ok, []} = Plumbing.ls_files_stage(repo) 24 | end 25 | 26 | test "happy path: can read from command-line git (empty index)" do 27 | %{xgit_path: ref, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 28 | 29 | {_output, 0} = 30 | System.cmd( 31 | "git", 32 | [ 33 | "update-index", 34 | "--add", 35 | "--cacheinfo", 36 | "100644", 37 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 38 | "hello.txt" 39 | ], 40 | cd: ref 41 | ) 42 | 43 | {_output, 0} = 44 | System.cmd( 45 | "git", 46 | [ 47 | "update-index", 48 | "--remove", 49 | "hello.txt" 50 | ], 51 | cd: ref 52 | ) 53 | 54 | assert {:ok, []} = Plumbing.ls_files_stage(repo) 55 | end 56 | 57 | test "happy path: can read from command-line git (two small files)" do 58 | %{xgit_path: ref, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 59 | 60 | {_output, 0} = 61 | System.cmd( 62 | "git", 63 | [ 64 | "update-index", 65 | "--add", 66 | "--cacheinfo", 67 | "100644", 68 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 69 | "hello.txt" 70 | ], 71 | cd: ref 72 | ) 73 | 74 | {_output, 0} = 75 | System.cmd( 76 | "git", 77 | [ 78 | "update-index", 79 | "--add", 80 | "--cacheinfo", 81 | "100644", 82 | "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 83 | "test_content.txt" 84 | ], 85 | cd: ref 86 | ) 87 | 88 | assert {:ok, entries} = Plumbing.ls_files_stage(repo) 89 | 90 | assert entries = [ 91 | %DirCacheEntry{ 92 | assume_valid?: false, 93 | ctime: 0, 94 | ctime_ns: 0, 95 | dev: 0, 96 | extended?: false, 97 | gid: 0, 98 | ino: 0, 99 | intent_to_add?: false, 100 | mode: 0o100644, 101 | mtime: 0, 102 | mtime_ns: 0, 103 | name: 'hello.txt', 104 | object_id: "18832d35117ef2f013c4009f5b2128dfaeff354f", 105 | size: 0, 106 | skip_worktree?: false, 107 | stage: 0, 108 | uid: 0 109 | }, 110 | %DirCacheEntry{ 111 | assume_valid?: false, 112 | ctime: 0, 113 | ctime_ns: 0, 114 | dev: 0, 115 | extended?: false, 116 | gid: 0, 117 | ino: 0, 118 | intent_to_add?: false, 119 | mode: 0o100644, 120 | mtime: 0, 121 | mtime_ns: 0, 122 | name: 'test_content.txt', 123 | object_id: "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 124 | size: 0, 125 | skip_worktree?: false, 126 | stage: 0, 127 | uid: 0 128 | } 129 | ] 130 | end 131 | 132 | test "error: repository invalid (not PID)" do 133 | assert_raise FunctionClauseError, fn -> 134 | Plumbing.ls_files_stage("xgit repo") 135 | end 136 | end 137 | 138 | test "error: repository invalid (PID, but not repo)" do 139 | {:ok, not_repo} = GenServer.start_link(NotValid, nil) 140 | 141 | assert_raise InvalidRepositoryError, fn -> 142 | Plumbing.ls_files_stage(not_repo) 143 | end 144 | end 145 | 146 | test "error: no working tree" do 147 | {:ok, repo} = InMemory.start_link() 148 | assert {:error, :bare} = Plumbing.ls_files_stage(repo) 149 | end 150 | 151 | test "error: invalid index file" do 152 | %{tmp_dir: xgit} = TempDirTestCase.tmp_dir!() 153 | 154 | git_dir = Path.join(xgit, ".git") 155 | File.mkdir_p!(git_dir) 156 | 157 | index_path = Path.join(git_dir, "index") 158 | File.write!(index_path, "DIRX") 159 | 160 | {:ok, repo} = OnDisk.start_link(work_dir: xgit) 161 | 162 | assert {:error, :invalid_format} = Plumbing.ls_files_stage(repo) 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /test/xgit/repository/plumbing/put_symbolic_ref_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.Plumbing.PutSymbolicRefTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Ref 5 | alias Xgit.Repository.InvalidRepositoryError 6 | alias Xgit.Repository.Plumbing 7 | alias Xgit.Repository.Storage 8 | alias Xgit.Test.OnDiskRepoTestCase 9 | 10 | import FolderDiff 11 | 12 | describe "put_symbolic_ref/4" do 13 | test "happy path: target ref does not exist" do 14 | %{xgit_path: path, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 15 | 16 | assert :ok = Plumbing.put_symbolic_ref(repo, "HEAD", "refs/heads/nope") 17 | 18 | assert {"refs/heads/nope\n", 0} = System.cmd("git", ["symbolic-ref", "HEAD"], cd: path) 19 | end 20 | 21 | test "error: posix error (dir where file should be)" do 22 | %{xgit_repo: repo, xgit_path: xgit_path} = OnDiskRepoTestCase.repo!() 23 | 24 | File.mkdir_p!(Path.join(xgit_path, ".git/refs/heads/whatever")) 25 | 26 | assert {:error, :eisdir} = 27 | Plumbing.put_symbolic_ref(repo, "refs/heads/whatever", "refs/heads/master") 28 | end 29 | 30 | test "error: posix error (file where dir should be)" do 31 | %{xgit_repo: repo, xgit_path: xgit_path} = OnDiskRepoTestCase.repo!() 32 | 33 | File.write!(Path.join(xgit_path, ".git/refs/heads/sub"), "oops, not a directory") 34 | 35 | assert {:error, :eexist} = 36 | Plumbing.put_symbolic_ref(repo, "refs/heads/sub/master", "refs/heads/master") 37 | end 38 | 39 | test "follows HEAD reference after it changes" do 40 | %{xgit_repo: repo} = OnDiskRepoTestCase.repo!() 41 | 42 | {:ok, commit_id_master} = 43 | Plumbing.hash_object('shhh... not really a commit', 44 | repo: repo, 45 | type: :commit, 46 | validate?: false, 47 | write?: true 48 | ) 49 | 50 | master_ref = %Ref{ 51 | name: "refs/heads/master", 52 | target: commit_id_master 53 | } 54 | 55 | master_ref_via_head = %Ref{ 56 | name: "HEAD", 57 | target: commit_id_master, 58 | link_target: "refs/heads/master" 59 | } 60 | 61 | assert :ok = Plumbing.update_ref(repo, "HEAD", commit_id_master) 62 | 63 | assert {:ok, [^master_ref]} = Storage.list_refs(repo) 64 | assert {:ok, ^master_ref} = Storage.get_ref(repo, "refs/heads/master") 65 | assert {:ok, ^master_ref_via_head} = Storage.get_ref(repo, "HEAD") 66 | 67 | {:ok, commit_id_other} = 68 | Plumbing.hash_object('shhh... another not commit', 69 | repo: repo, 70 | type: :commit, 71 | validate?: false, 72 | write?: true 73 | ) 74 | 75 | other_ref = %Ref{ 76 | name: "refs/heads/other", 77 | target: commit_id_other 78 | } 79 | 80 | other_ref_via_head = %Ref{ 81 | name: "HEAD", 82 | target: commit_id_other, 83 | link_target: "refs/heads/other" 84 | } 85 | 86 | assert :ok = Plumbing.put_symbolic_ref(repo, "HEAD", "refs/heads/other") 87 | 88 | assert :ok = Plumbing.update_ref(repo, "HEAD", commit_id_other) 89 | 90 | assert {:ok, [^master_ref, ^other_ref]} = Storage.list_refs(repo) 91 | assert {:ok, ^master_ref} = Storage.get_ref(repo, "refs/heads/master") 92 | assert {:ok, ^other_ref_via_head} = Storage.get_ref(repo, "HEAD") 93 | end 94 | 95 | test "result can be read by command-line git" do 96 | %{xgit_repo: repo, xgit_path: path} = OnDiskRepoTestCase.repo!() 97 | 98 | assert :ok = Plumbing.put_symbolic_ref(repo, "HEAD", "refs/heads/other") 99 | assert {"refs/heads/other\n", 0} = System.cmd("git", ["symbolic-ref", "HEAD"], cd: path) 100 | end 101 | 102 | test "matches command-line output" do 103 | %{xgit_path: xgit_path, xgit_repo: xgit_repo} = OnDiskRepoTestCase.repo!() 104 | %{xgit_path: ref_path} = OnDiskRepoTestCase.repo!() 105 | 106 | {_, 0} = System.cmd("git", ["symbolic-ref", "HEAD", "refs/heads/other"], cd: ref_path) 107 | 108 | :ok = Plumbing.put_symbolic_ref(xgit_repo, "HEAD", "refs/heads/other") 109 | 110 | assert_folders_are_equal(ref_path, xgit_path) 111 | end 112 | 113 | test "error: repository invalid (not PID)" do 114 | assert_raise FunctionClauseError, fn -> 115 | Plumbing.put_symbolic_ref("xgit repo", "HEAD", "refs/heads/master") 116 | end 117 | end 118 | 119 | test "error: repository invalid (PID, but not repo)" do 120 | {:ok, not_repo} = GenServer.start_link(NotValid, nil) 121 | 122 | assert_raise InvalidRepositoryError, fn -> 123 | Plumbing.put_symbolic_ref(not_repo, "HEAD", "refs/heads/master") 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/xgit/repository/storage_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.StorageTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Repository.InMemory 5 | alias Xgit.Repository.InvalidRepositoryError 6 | alias Xgit.Repository.Storage 7 | 8 | describe "assert_valid/1" do 9 | test "remembers a previously valid PID" do 10 | {:ok, repo} = InMemory.start_link() 11 | 12 | assert {:xgit_repo, repo} = Storage.assert_valid(repo) 13 | assert {:xgit_repo, repo} = Storage.assert_valid({:xgit_repo, repo}) 14 | assert Storage.valid?({:xgit_repo, repo}) 15 | end 16 | 17 | test "raises InvalidRepositoryError when invalid PID" do 18 | {:ok, pid} = GenServer.start_link(NotValid, nil) 19 | 20 | assert_raise InvalidRepositoryError, fn -> 21 | Storage.assert_valid(pid) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/xgit/repository/working_tree/dir_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.WorkingTree.DirCacheTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.DirCache 5 | alias Xgit.Repository.InMemory 6 | alias Xgit.Repository.OnDisk 7 | alias Xgit.Repository.Storage 8 | alias Xgit.Repository.WorkingTree 9 | alias Xgit.Test.OnDiskRepoTestCase 10 | 11 | describe "dir_cache/1" do 12 | test "happy path: no index file" do 13 | Temp.track!() 14 | path = Temp.path!() 15 | 16 | {:ok, repo} = InMemory.start_link() 17 | {:ok, working_tree} = WorkingTree.start_link(repo, path) 18 | 19 | assert {:ok, %DirCache{entry_count: 0} = dir_cache} = WorkingTree.dir_cache(working_tree) 20 | assert DirCache.valid?(dir_cache) 21 | end 22 | 23 | test "happy path: can read from command-line git (empty index)" do 24 | %{xgit_path: ref, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 25 | 26 | {_output, 0} = 27 | System.cmd( 28 | "git", 29 | [ 30 | "update-index", 31 | "--add", 32 | "--cacheinfo", 33 | "100644", 34 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 35 | "hello.txt" 36 | ], 37 | cd: ref 38 | ) 39 | 40 | {_output, 0} = 41 | System.cmd( 42 | "git", 43 | [ 44 | "update-index", 45 | "--remove", 46 | "hello.txt" 47 | ], 48 | cd: ref 49 | ) 50 | 51 | working_tree = Storage.default_working_tree(repo) 52 | 53 | assert {:ok, %DirCache{entry_count: 0} = dir_cache} = WorkingTree.dir_cache(working_tree) 54 | assert DirCache.valid?(dir_cache) 55 | end 56 | 57 | test "happy path: can read from command-line git (two small files)" do 58 | %{xgit_path: ref, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 59 | 60 | {_output, 0} = 61 | System.cmd( 62 | "git", 63 | [ 64 | "update-index", 65 | "--add", 66 | "--cacheinfo", 67 | "100644", 68 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 69 | "hello.txt" 70 | ], 71 | cd: ref 72 | ) 73 | 74 | {_output, 0} = 75 | System.cmd( 76 | "git", 77 | [ 78 | "update-index", 79 | "--add", 80 | "--cacheinfo", 81 | "100644", 82 | "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 83 | "test_content.txt" 84 | ], 85 | cd: ref 86 | ) 87 | 88 | working_tree = Storage.default_working_tree(repo) 89 | 90 | assert {:ok, %DirCache{} = dir_cache} = WorkingTree.dir_cache(working_tree) 91 | assert DirCache.valid?(dir_cache) 92 | 93 | assert dir_cache = %DirCache{ 94 | entries: [ 95 | %DirCache.Entry{ 96 | assume_valid?: false, 97 | ctime: 0, 98 | ctime_ns: 0, 99 | dev: 0, 100 | extended?: false, 101 | gid: 0, 102 | ino: 0, 103 | intent_to_add?: false, 104 | mode: 0o100644, 105 | mtime: 0, 106 | mtime_ns: 0, 107 | name: 'hello.txt', 108 | object_id: "18832d35117ef2f013c4009f5b2128dfaeff354f", 109 | size: 0, 110 | skip_worktree?: false, 111 | stage: 0, 112 | uid: 0 113 | }, 114 | %DirCache.Entry{ 115 | assume_valid?: false, 116 | ctime: 0, 117 | ctime_ns: 0, 118 | dev: 0, 119 | extended?: false, 120 | gid: 0, 121 | ino: 0, 122 | intent_to_add?: false, 123 | mode: 0o100644, 124 | mtime: 0, 125 | mtime_ns: 0, 126 | name: 'test_content.txt', 127 | object_id: "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 128 | size: 0, 129 | skip_worktree?: false, 130 | stage: 0, 131 | uid: 0 132 | } 133 | ], 134 | entry_count: 2, 135 | version: 2 136 | } 137 | end 138 | 139 | test "error: file doesn't start with DIRC signature" do 140 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 141 | assert {:error, :invalid_format} = parse_iodata_as_index_file(xgit, 'DIRX') 142 | end 143 | 144 | test "error: unsupported version" do 145 | %{xgit_path: xgit} = OnDiskRepoTestCase.repo!() 146 | 147 | assert {:error, :unsupported_version} = 148 | parse_iodata_as_index_file(xgit, [ 149 | 'DIRC', 150 | 0, 151 | 0, 152 | 0, 153 | 1, 154 | 0, 155 | 0, 156 | 0, 157 | 0, 158 | 0, 159 | 0, 160 | 0, 161 | 0, 162 | 0, 163 | 0, 164 | 0, 165 | 0, 166 | 0, 167 | 0, 168 | 0, 169 | 0, 170 | 0, 171 | 0, 172 | 0, 173 | 0 174 | ]) 175 | end 176 | 177 | test "error: 'index' is a directory" do 178 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 179 | 180 | index_path = Path.join([xgit, ".git", "index"]) 181 | File.mkdir_p!(index_path) 182 | 183 | # ^ WRONG! Should be a file, not a directory. 184 | 185 | working_tree = Storage.default_working_tree(repo) 186 | 187 | assert {:error, :eisdir} = WorkingTree.dir_cache(working_tree) 188 | end 189 | end 190 | 191 | defp parse_iodata_as_index_file(xgit, iodata) do 192 | git_dir = Path.join(xgit, ".git") 193 | File.mkdir_p!(git_dir) 194 | 195 | index_path = Path.join(git_dir, "index") 196 | File.write!(index_path, iodata) 197 | 198 | {:ok, repo} = OnDisk.start_link(work_dir: xgit) 199 | working_tree = Storage.default_working_tree(repo) 200 | 201 | WorkingTree.dir_cache(working_tree) 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /test/xgit/repository/working_tree/reset_dir_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.WorkingTree.ResetDirCacheTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.DirCache 5 | alias Xgit.Repository.Storage 6 | alias Xgit.Repository.WorkingTree 7 | alias Xgit.Test.OnDiskRepoTestCase 8 | 9 | import FolderDiff 10 | 11 | describe "reset_dir_cache/1" do 12 | test "happy path: can generate correct empty index file" do 13 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 14 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 15 | 16 | # An initialized git repo doesn't have an index file at all. 17 | # Adding and removing a file generates an empty index file. 18 | 19 | {_output, 0} = 20 | System.cmd( 21 | "git", 22 | [ 23 | "update-index", 24 | "--add", 25 | "--cacheinfo", 26 | "100644", 27 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 28 | "hello.txt" 29 | ], 30 | cd: ref 31 | ) 32 | 33 | {_output, 0} = 34 | System.cmd( 35 | "git", 36 | [ 37 | "update-index", 38 | "--remove", 39 | "hello.txt" 40 | ], 41 | cd: ref 42 | ) 43 | 44 | working_tree = Storage.default_working_tree(repo) 45 | 46 | assert :ok = WorkingTree.reset_dir_cache(working_tree) 47 | 48 | assert_folders_are_equal(ref, xgit) 49 | end 50 | 51 | test "can reset an index file when entries existed" do 52 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 53 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 54 | 55 | {_output, 0} = 56 | System.cmd( 57 | "git", 58 | [ 59 | "update-index", 60 | "--add", 61 | "--cacheinfo", 62 | "100644", 63 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 64 | "hello.txt" 65 | ], 66 | cd: ref 67 | ) 68 | 69 | {_output, 0} = 70 | System.cmd( 71 | "git", 72 | [ 73 | "update-index", 74 | "--add", 75 | "--cacheinfo", 76 | "100644", 77 | "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 78 | "test_content.txt" 79 | ], 80 | cd: ref 81 | ) 82 | 83 | working_tree = Storage.default_working_tree(repo) 84 | 85 | assert :ok = 86 | WorkingTree.update_dir_cache( 87 | working_tree, 88 | [ 89 | %DirCache.Entry{ 90 | assume_valid?: false, 91 | ctime: 0, 92 | ctime_ns: 0, 93 | dev: 0, 94 | extended?: false, 95 | gid: 0, 96 | ino: 0, 97 | intent_to_add?: false, 98 | mode: 0o100644, 99 | mtime: 0, 100 | mtime_ns: 0, 101 | name: 'hello.txt', 102 | object_id: "18832d35117ef2f013c4009f5b2128dfaeff354f", 103 | size: 0, 104 | skip_worktree?: false, 105 | stage: 0, 106 | uid: 0 107 | }, 108 | %DirCache.Entry{ 109 | assume_valid?: false, 110 | ctime: 0, 111 | ctime_ns: 0, 112 | dev: 0, 113 | extended?: false, 114 | gid: 0, 115 | ino: 0, 116 | intent_to_add?: false, 117 | mode: 0o100644, 118 | mtime: 0, 119 | mtime_ns: 0, 120 | name: 'test_content.txt', 121 | object_id: "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 122 | size: 0, 123 | skip_worktree?: false, 124 | stage: 0, 125 | uid: 0 126 | } 127 | ], 128 | [] 129 | ) 130 | 131 | assert_folders_are_equal(ref, xgit) 132 | 133 | {_output, 0} = 134 | System.cmd( 135 | "git", 136 | [ 137 | "update-index", 138 | "--remove", 139 | "hello.txt" 140 | ], 141 | cd: ref 142 | ) 143 | 144 | {_output, 0} = 145 | System.cmd( 146 | "git", 147 | [ 148 | "update-index", 149 | "--remove", 150 | "test_content.txt" 151 | ], 152 | cd: ref 153 | ) 154 | 155 | assert :ok = WorkingTree.reset_dir_cache(working_tree) 156 | 157 | assert_folders_are_equal(ref, xgit) 158 | end 159 | 160 | test "error: can't replace malformed index file" do 161 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 162 | 163 | git_dir = Path.join(xgit, '.git') 164 | File.mkdir_p!(git_dir) 165 | 166 | index = Path.join(git_dir, 'index') 167 | File.mkdir_p!(index) 168 | 169 | working_tree = Storage.default_working_tree(repo) 170 | 171 | assert {:error, :eisdir} = WorkingTree.reset_dir_cache(working_tree) 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /test/xgit/repository/working_tree/update_dir_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.WorkingTree.UpdateDirCacheTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.DirCache 5 | alias Xgit.Repository.Storage 6 | alias Xgit.Repository.WorkingTree 7 | alias Xgit.Test.OnDiskRepoTestCase 8 | 9 | import FolderDiff 10 | 11 | describe "update_dir_cache/1" do 12 | test "happy path: can generate correct empty index file" do 13 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 14 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 15 | 16 | # An initialized git repo doesn't have an index file at all. 17 | # Adding and removing a file generates an empty index file. 18 | 19 | {_output, 0} = 20 | System.cmd( 21 | "git", 22 | [ 23 | "update-index", 24 | "--add", 25 | "--cacheinfo", 26 | "100644", 27 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 28 | "hello.txt" 29 | ], 30 | cd: ref 31 | ) 32 | 33 | {_output, 0} = 34 | System.cmd( 35 | "git", 36 | [ 37 | "update-index", 38 | "--remove", 39 | "hello.txt" 40 | ], 41 | cd: ref 42 | ) 43 | 44 | working_tree = Storage.default_working_tree(repo) 45 | 46 | assert :ok = WorkingTree.update_dir_cache(working_tree, [], []) 47 | 48 | assert_folders_are_equal(ref, xgit) 49 | end 50 | 51 | test "can write an index file with entries that matches command-line git" do 52 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 53 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 54 | 55 | {_output, 0} = 56 | System.cmd( 57 | "git", 58 | [ 59 | "update-index", 60 | "--add", 61 | "--cacheinfo", 62 | "100644", 63 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 64 | "hello.txt" 65 | ], 66 | cd: ref 67 | ) 68 | 69 | {_output, 0} = 70 | System.cmd( 71 | "git", 72 | [ 73 | "update-index", 74 | "--add", 75 | "--cacheinfo", 76 | "100644", 77 | "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 78 | "test_content.txt" 79 | ], 80 | cd: ref 81 | ) 82 | 83 | working_tree = Storage.default_working_tree(repo) 84 | 85 | assert :ok = 86 | WorkingTree.update_dir_cache( 87 | working_tree, 88 | [ 89 | %DirCache.Entry{ 90 | assume_valid?: false, 91 | ctime: 0, 92 | ctime_ns: 0, 93 | dev: 0, 94 | extended?: false, 95 | gid: 0, 96 | ino: 0, 97 | intent_to_add?: false, 98 | mode: 0o100644, 99 | mtime: 0, 100 | mtime_ns: 0, 101 | name: 'hello.txt', 102 | object_id: "18832d35117ef2f013c4009f5b2128dfaeff354f", 103 | size: 0, 104 | skip_worktree?: false, 105 | stage: 0, 106 | uid: 0 107 | }, 108 | %DirCache.Entry{ 109 | assume_valid?: false, 110 | ctime: 0, 111 | ctime_ns: 0, 112 | dev: 0, 113 | extended?: false, 114 | gid: 0, 115 | ino: 0, 116 | intent_to_add?: false, 117 | mode: 0o100644, 118 | mtime: 0, 119 | mtime_ns: 0, 120 | name: 'test_content.txt', 121 | object_id: "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 122 | size: 0, 123 | skip_worktree?: false, 124 | stage: 0, 125 | uid: 0 126 | } 127 | ], 128 | [] 129 | ) 130 | 131 | assert_folders_are_equal(ref, xgit) 132 | end 133 | 134 | test "can remove entries from index file" do 135 | %{xgit_path: ref} = OnDiskRepoTestCase.repo!() 136 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 137 | 138 | {_output, 0} = 139 | System.cmd( 140 | "git", 141 | [ 142 | "update-index", 143 | "--add", 144 | "--cacheinfo", 145 | "100644", 146 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 147 | "hello.txt" 148 | ], 149 | cd: ref 150 | ) 151 | 152 | # For variety, let's use command-line git to write the first index file 153 | # and then update it with Xgit. 154 | 155 | {_output, 0} = 156 | System.cmd( 157 | "git", 158 | [ 159 | "update-index", 160 | "--add", 161 | "--cacheinfo", 162 | "100644", 163 | "18832d35117ef2f013c4009f5b2128dfaeff354f", 164 | "hello.txt" 165 | ], 166 | cd: xgit 167 | ) 168 | 169 | {_output, 0} = 170 | System.cmd( 171 | "git", 172 | [ 173 | "update-index", 174 | "--add", 175 | "--cacheinfo", 176 | "100644", 177 | "d670460b4b4aece5915caf5c68d12f560a9fe3e4", 178 | "test_content.txt" 179 | ], 180 | cd: xgit 181 | ) 182 | 183 | working_tree = Storage.default_working_tree(repo) 184 | 185 | assert :ok = 186 | assert( 187 | :ok = 188 | WorkingTree.update_dir_cache( 189 | working_tree, 190 | [], 191 | [{'test_content.txt', 0}] 192 | ) 193 | ) 194 | 195 | assert_folders_are_equal(ref, xgit) 196 | end 197 | 198 | test "error: file doesn't start with DIRC signature" do 199 | %{xgit_path: xgit, xgit_repo: repo} = OnDiskRepoTestCase.repo!() 200 | 201 | git_dir = Path.join(xgit, '.git') 202 | File.mkdir_p!(git_dir) 203 | 204 | index = Path.join(git_dir, 'index') 205 | File.write!(index, 'DIRX12345678901234567890') 206 | 207 | working_tree = Storage.default_working_tree(repo) 208 | 209 | assert {:error, :invalid_format} = 210 | WorkingTree.update_dir_cache( 211 | working_tree, 212 | [], 213 | [{'test_content.txt', 0}] 214 | ) 215 | end 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /test/xgit/repository/working_tree_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Repository.WorkingTreeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Repository.InMemory 5 | alias Xgit.Repository.InvalidRepositoryError 6 | alias Xgit.Repository.WorkingTree 7 | 8 | import ExUnit.CaptureLog 9 | 10 | describe "valid?/1" do 11 | # Happy path covered by start_link/1 test below. 12 | 13 | test "different process" do 14 | {:ok, not_working_tree} = GenServer.start_link(NotValid, nil) 15 | refute WorkingTree.valid?(not_working_tree) 16 | end 17 | 18 | test "different types" do 19 | refute WorkingTree.valid?(42) 20 | refute WorkingTree.valid?("so-called working tree") 21 | end 22 | end 23 | 24 | describe "start_link/1" do 25 | test "happy path: starts and is valid" do 26 | Temp.track!() 27 | path = Temp.path!() 28 | 29 | {:ok, repo} = InMemory.start_link() 30 | 31 | assert {:ok, working_tree} = WorkingTree.start_link(repo, path) 32 | assert is_pid(working_tree) 33 | 34 | assert WorkingTree.valid?(working_tree) 35 | assert File.dir?(path) 36 | end 37 | 38 | test "handles unknown message" do 39 | Temp.track!() 40 | path = Temp.path!() 41 | 42 | {:ok, repo} = InMemory.start_link() 43 | 44 | assert {:ok, working_tree} = WorkingTree.start_link(repo, path) 45 | 46 | assert capture_log(fn -> 47 | assert {:error, :unknown_message} = 48 | GenServer.call(working_tree, :random_unknown_message) 49 | end) =~ "WorkingTree received unrecognized call :random_unknown_message" 50 | end 51 | 52 | test "error: repository isn't" do 53 | Temp.track!() 54 | path = Temp.path!() 55 | 56 | {:ok, not_repo} = GenServer.start_link(NotValid, nil) 57 | 58 | assert_raise InvalidRepositoryError, fn -> 59 | WorkingTree.start_link(not_repo, path) 60 | end 61 | end 62 | 63 | test "error: can't create working dir" do 64 | Temp.track!() 65 | path = Temp.path!() 66 | File.write!(path, "not a directory") 67 | 68 | {:ok, repo} = InMemory.start_link() 69 | assert {:error, {:mkdir, :eexist}} = WorkingTree.start_link(repo, path) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/xgit/tree/entry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Tree.EntryTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Tree.Entry 5 | 6 | @valid %Entry{ 7 | name: 'hello.txt', 8 | object_id: "7919e8900c3af541535472aebd56d44222b7b3a3", 9 | mode: 0o100644 10 | } 11 | 12 | describe "valid?/1" do 13 | test "happy path: valid entry" do 14 | assert Entry.valid?(@valid) 15 | end 16 | 17 | @invalid_mods [ 18 | name: "binary, not byte list", 19 | name: '', 20 | name: '/absolute/path', 21 | object_id: "7919e8900c3af541535472aebd56d44222b7b3a", 22 | object_id: "7919e8900c3af541535472aebd56d44222b7b3a34", 23 | object_id: "0000000000000000000000000000000000000000", 24 | mode: 0, 25 | mode: 0o100645 26 | ] 27 | 28 | test "invalid entries" do 29 | Enum.each(@invalid_mods, fn {key, value} -> 30 | invalid = Map.put(@valid, key, value) 31 | 32 | refute( 33 | Entry.valid?(invalid), 34 | "incorrectly accepted entry with :#{key} set to #{inspect(value)}" 35 | ) 36 | end) 37 | end 38 | end 39 | 40 | describe "compare/2" do 41 | test "special case: nil sorts first" do 42 | assert Entry.compare(nil, @valid) == :lt 43 | end 44 | 45 | test "equality" do 46 | assert Entry.compare(@valid, @valid) == :eq 47 | end 48 | 49 | @name_gt Map.put(@valid, :name, 'later.txt') 50 | test "comparison based on name" do 51 | assert Entry.compare(@valid, @name_gt) == :lt 52 | assert Entry.compare(@name_gt, @valid) == :gt 53 | end 54 | 55 | @mode_gt Map.put(@valid, :mode, 0o100755) 56 | test "doesn't compare based on mode" do 57 | assert Entry.compare(@valid, @mode_gt) == :eq 58 | assert Entry.compare(@mode_gt, @valid) == :eq 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/xgit/util/file_utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.FileUtilsTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Xgit.Test.TempDirTestCase 5 | 6 | alias Xgit.Util.FileUtils 7 | 8 | describe "recursive_files!/1" do 9 | test "empty dir" do 10 | %{tmp_dir: tmp} = tmp_dir!() 11 | assert [] = FileUtils.recursive_files!(tmp) 12 | end 13 | 14 | test "dir doesn't exist" do 15 | %{tmp_dir: tmp} = tmp_dir!() 16 | foo_path = Path.join(tmp, "foo") 17 | assert [] = FileUtils.recursive_files!(foo_path) 18 | end 19 | 20 | test "one file" do 21 | %{tmp_dir: tmp} = tmp_dir!() 22 | foo_path = Path.join(tmp, "foo") 23 | File.write!(foo_path, "foo") 24 | assert [^foo_path] = FileUtils.recursive_files!(tmp) 25 | end 26 | 27 | test "one file, nested" do 28 | %{tmp_dir: tmp} = tmp_dir!() 29 | bar_dir_path = Path.join(tmp, "bar") 30 | File.mkdir_p!(bar_dir_path) 31 | foo_path = Path.join(bar_dir_path, "foo") 32 | File.write!(foo_path, "foo") 33 | assert [^foo_path] = FileUtils.recursive_files!(tmp) 34 | end 35 | 36 | test "three file" do 37 | %{tmp_dir: tmp} = tmp_dir!() 38 | 39 | foo_path = Path.join(tmp, "foo") 40 | File.write!(foo_path, "foo") 41 | 42 | bar_path = Path.join(tmp, "bar") 43 | File.write!(bar_path, "bar") 44 | 45 | blah_path = Path.join(tmp, "blah") 46 | File.write!(blah_path, "blah") 47 | 48 | assert [^bar_path, ^blah_path, ^foo_path] = 49 | tmp 50 | |> FileUtils.recursive_files!() 51 | |> Enum.sort() 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/xgit/util/nb_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2008, 2015 Google Inc. 2 | # and other copyright owners as documented in the project's IP log. 3 | # 4 | # Elixir adaptation from jgit file: 5 | # org.eclipse.jgit.test/tst/org/eclipse/jgit/util/NBTest.java 6 | # 7 | # Copyright (C) 2019, Eric Scouten 8 | # 9 | # This program and the accompanying materials are made available 10 | # under the terms of the Eclipse Distribution License v1.0 which 11 | # accompanies this distribution, is reproduced below, and is 12 | # available at http://www.eclipse.org/org/documents/edl-v10.php 13 | # 14 | # All rights reserved. 15 | # 16 | # Redistribution and use in source and binary forms, with or 17 | # without modification, are permitted provided that the following 18 | # conditions are met: 19 | # 20 | # - Redistributions of source code must retain the above copyright 21 | # notice, this list of conditions and the following disclaimer. 22 | # 23 | # - Redistributions in binary form must reproduce the above 24 | # copyright notice, this list of conditions and the following 25 | # disclaimer in the documentation and/or other materials provided 26 | # with the distribution. 27 | # 28 | # - Neither the name of the Eclipse Foundation, Inc. nor the 29 | # names of its contributors may be used to endorse or promote 30 | # products derived from this software without specific prior 31 | # written permission. 32 | # 33 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 34 | # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 35 | # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 36 | # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 37 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 38 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 39 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 40 | # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 41 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 42 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 43 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 44 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 45 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 46 | 47 | defmodule Xgit.Util.NBTest do 48 | use ExUnit.Case, async: true 49 | 50 | alias Xgit.Util.NB 51 | 52 | describe "decode_int32/1" do 53 | test "simple cases" do 54 | assert NB.decode_int32([0, 0, 0, 0, 0]) == {0, [0]} 55 | assert NB.decode_int32([0, 0, 0, 0, 3]) == {0, [3]} 56 | 57 | assert NB.decode_int32([0, 0, 0, 0, 3, 42]) == {0, [3, 42]} 58 | 59 | assert NB.decode_int32([0, 0, 0, 3]) == {3, []} 60 | assert NB.decode_int32([0, 0, 0, 3, 0]) == {3, [0]} 61 | assert NB.decode_int32([0, 0, 0, 3, 3]) == {3, [3]} 62 | 63 | assert NB.decode_int32([0x03, 0x10, 0xAD, 0xEF, 1]) == {0x0310ADEF, [1]} 64 | end 65 | 66 | test "negative numbers" do 67 | assert NB.decode_int32([0xFF, 0xFF, 0xFF, 0xFF, 0xFE]) == {-1, [0xFE]} 68 | assert NB.decode_int32([0xDE, 0xAD, 0xBE, 0xEF, 1]) == {-559_038_737, [1]} 69 | end 70 | 71 | test "rejects byte list too short" do 72 | assert_raise FunctionClauseError, fn -> 73 | NB.decode_int32([1, 2, 3]) 74 | end 75 | end 76 | end 77 | 78 | describe "decode_uint16/1" do 79 | test "simple cases" do 80 | assert NB.decode_uint16([0, 0, 0]) == {0, [0]} 81 | assert NB.decode_uint16([0, 0, 3]) == {0, [3]} 82 | 83 | assert NB.decode_uint16([0, 0, 3, 42]) == {0, [3, 42]} 84 | 85 | assert NB.decode_uint16([0, 3]) == {3, []} 86 | assert NB.decode_uint16([0, 3, 0]) == {3, [0]} 87 | assert NB.decode_uint16([0, 3, 3]) == {3, [3]} 88 | 89 | assert NB.decode_uint16([0xAD, 0xEF, 1]) == {0xADEF, [1]} 90 | 91 | assert NB.decode_uint16([0xFF, 0xFF, 0xFE]) == {0xFFFF, [0xFE]} 92 | assert NB.decode_uint16([0xBE, 0xEF, 1]) == {0xBEEF, [1]} 93 | end 94 | 95 | test "rejects byte list too short" do 96 | assert_raise FunctionClauseError, fn -> 97 | NB.decode_uint16([1]) 98 | end 99 | end 100 | end 101 | 102 | describe "decode_uint32/1" do 103 | test "simple cases" do 104 | assert NB.decode_uint32([0, 0, 0, 0, 0]) == {0, [0]} 105 | assert NB.decode_uint32([0, 0, 0, 0, 3]) == {0, [3]} 106 | 107 | assert NB.decode_uint32([0, 0, 0, 0, 3, 42]) == {0, [3, 42]} 108 | 109 | assert NB.decode_uint32([0, 0, 0, 3]) == {3, []} 110 | assert NB.decode_uint32([0, 0, 0, 3, 0]) == {3, [0]} 111 | assert NB.decode_uint32([0, 0, 0, 3, 3]) == {3, [3]} 112 | 113 | assert NB.decode_uint32([0x03, 0x10, 0xAD, 0xEF, 1]) == {0x0310ADEF, [1]} 114 | 115 | assert NB.decode_uint32([0xFF, 0xFF, 0xFF, 0xFF, 0xFE]) == {0xFFFFFFFF, [0xFE]} 116 | assert NB.decode_uint32([0xDE, 0xAD, 0xBE, 0xEF, 1]) == {0xDEADBEEF, [1]} 117 | end 118 | 119 | test "rejects byte list too short" do 120 | assert_raise FunctionClauseError, fn -> 121 | NB.decode_uint32([1, 2, 3]) 122 | end 123 | end 124 | end 125 | 126 | test "encode_int16/1" do 127 | assert NB.encode_int16(0) == [0, 0] 128 | assert NB.encode_int16(3) == [0, 3] 129 | assert NB.encode_int16(0xDEAC) == [0xDE, 0xAC] 130 | assert NB.encode_int16(-1) == [0xFF, 0xFF] 131 | end 132 | 133 | test "encode_int32/1" do 134 | assert NB.encode_int32(0) == [0, 0, 0, 0] 135 | assert NB.encode_int32(3) == [0, 0, 0, 3] 136 | assert NB.encode_int32(0xDEAC) == [0, 0, 0xDE, 0xAC] 137 | assert NB.encode_int32(0xDEAC9853) == [0xDE, 0xAC, 0x98, 0x53] 138 | assert NB.encode_int32(-1) == [0xFF, 0xFF, 0xFF, 0xFF] 139 | end 140 | 141 | test "encode_uint16/1" do 142 | assert NB.encode_uint16(0) == [0, 0] 143 | assert NB.encode_uint16(3) == [0, 3] 144 | assert NB.encode_uint16(0xDEAC) == [0xDE, 0xAC] 145 | 146 | assert_raise FunctionClauseError, fn -> NB.encode_uint16(-1) end 147 | end 148 | 149 | test "encode_uint32/1" do 150 | assert NB.encode_uint32(0) == [0, 0, 0, 0] 151 | assert NB.encode_uint32(3) == [0, 0, 0, 3] 152 | assert NB.encode_uint32(0xDEAC) == [0, 0, 0xDE, 0xAC] 153 | assert NB.encode_uint32(0xDEAC9853) == [0xDE, 0xAC, 0x98, 0x53] 154 | 155 | assert_raise FunctionClauseError, fn -> NB.encode_uint32(-1) end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /test/xgit/util/parse_charlist_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.ParseCharlistTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Util.ParseCharlist 5 | 6 | test "decode/1" do 7 | assert ParseCharlist.decode_ambiguous_charlist([64, 65, 66]) == "@AB" 8 | assert ParseCharlist.decode_ambiguous_charlist([228, 105, 116, 105]) == "äiti" 9 | assert ParseCharlist.decode_ambiguous_charlist([195, 164, 105, 116, 105]) == "äiti" 10 | assert ParseCharlist.decode_ambiguous_charlist([66, 106, 246, 114, 110]) == "Björn" 11 | assert ParseCharlist.decode_ambiguous_charlist([66, 106, 195, 182, 114, 110]) == "Björn" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/xgit/util/parse_decimal_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.ParseDecimalTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Xgit.Util.ParseDecimal 5 | 6 | test "from_decimal_charlist/1" do 7 | assert from_decimal_charlist('abc') == {0, 'abc'} 8 | assert from_decimal_charlist('0abc') == {0, 'abc'} 9 | assert from_decimal_charlist('99') == {99, ''} 10 | assert from_decimal_charlist('+99x') == {99, 'x'} 11 | assert from_decimal_charlist(' -42 ') == {-42, ' '} 12 | assert from_decimal_charlist(' xyz') == {0, 'xyz'} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/xgit/util/parse_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.ParseHeaderTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Util.ParseHeader 5 | 6 | describe "next_header/1" do 7 | test "happy path" do 8 | assert {'tree', 'abcdef', 'what remains\n'} = 9 | ParseHeader.next_header(~C""" 10 | tree abcdef 11 | what remains 12 | """) 13 | end 14 | 15 | test "happy path (last line)" do 16 | assert {'tree', 'abcdef', []} = 17 | ParseHeader.next_header(~C""" 18 | tree abcdef 19 | """) 20 | end 21 | 22 | test "happy path (no trailing LF)" do 23 | assert {'tree', 'abcdef', []} = ParseHeader.next_header('tree abcdef') 24 | end 25 | 26 | test "no header value" do 27 | assert :no_header_found = ParseHeader.next_header('abc') 28 | end 29 | 30 | test "no header value (trailing LF)" do 31 | assert :no_header_found = ParseHeader.next_header('abc\n') 32 | end 33 | 34 | test "empty charlist" do 35 | assert :no_header_found = ParseHeader.next_header([]) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/xgit/util/unzip_stream_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Xgit.Util.UnzipStreamTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Xgit.Util.UnzipStream 5 | 6 | @test_content_path Path.join(File.cwd!(), "test/fixtures/test_content.zip") 7 | @large_content_path Path.join(File.cwd!(), "test/fixtures/LICENSE_blob.zip") 8 | 9 | describe "unzip/1" do 10 | test "happy path (small file)" do 11 | assert 'blob 13\0test content\n' = 12 | @test_content_path 13 | |> File.stream!([:binary]) 14 | |> UnzipStream.unzip() 15 | |> Enum.to_list() 16 | end 17 | 18 | test "happy path (large file)" do 19 | license = File.read!("LICENSE") 20 | 21 | assert 'blob 11357\0#{license}' == 22 | @large_content_path 23 | |> File.stream!([:binary]) 24 | |> UnzipStream.unzip() 25 | |> Enum.to_list() 26 | end 27 | 28 | test "happy path (zero-byte source file)" do 29 | Temp.track!() 30 | tmp = Temp.path!() 31 | 32 | z = :zlib.open() 33 | :ok = :zlib.deflateInit(z, 1) 34 | compressed = :zlib.deflate(z, [], :finish) 35 | :zlib.deflateEnd(z) 36 | 37 | File.write!(tmp, compressed) 38 | 39 | uncompressed = 40 | tmp 41 | |> File.stream!([:binary], 16_384) 42 | |> UnzipStream.unzip() 43 | |> Enum.to_list() 44 | 45 | assert uncompressed == [] 46 | end 47 | 48 | test "happy path (extra large random file)" do 49 | Temp.track!() 50 | tmp = Temp.path!() 51 | 52 | # Yes, we're abusing the VM a bit here. 53 | # Large blocks like this ... maybe not the best idea. 54 | 55 | random_bytes = :crypto.strong_rand_bytes(1_048_576) 56 | 57 | z = :zlib.open() 58 | :ok = :zlib.deflateInit(z, 1) 59 | compressed = :zlib.deflate(z, random_bytes, :finish) 60 | :zlib.deflateEnd(z) 61 | 62 | File.write!(tmp, compressed) 63 | 64 | uncompressed = 65 | tmp 66 | |> File.stream!([:binary], 16_384) 67 | |> UnzipStream.unzip() 68 | |> Enum.to_list() 69 | |> :binary.list_to_bin() 70 | 71 | assert uncompressed == random_bytes 72 | end 73 | 74 | test "error: file isn't a zip" do 75 | assert_raise ErlangError, "Erlang error: :data_error", fn -> 76 | "LICENSE" 77 | |> File.stream!([:binary]) 78 | |> UnzipStream.unzip() 79 | |> Enum.to_list() 80 | end 81 | end 82 | end 83 | end 84 | --------------------------------------------------------------------------------