├── .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
10 |
11 | Pure Elixir native implementation of git
12 |
13 | 
14 | [](https://codecov.io/gh/elixir-git/xgit)
15 | [](https://hex.pm/packages/xgit)
16 | [](https://hexdocs.pm/xgit)
17 | [](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 |
--------------------------------------------------------------------------------