thuban 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -1
- data/README.md +124 -28
- data/lib/thuban/ignore_matcher.rb +153 -7
- data/lib/thuban/version.rb +1 -1
- data/sig/thuban.rbs +14 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f141979f71fdcf5cb0cc8b6678e084569285d9d3ee9f21bf2e584dbeb98a9139
|
|
4
|
+
data.tar.gz: 9fbdf6deec48a31479b5d2b12f564beeaac8cde969d9f50a68f476d89011e59d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b98d9e65ebe84062f9960c19557a871a80344bcbdd9c9bbb77ae9ffe8e30579464249be003a6f26831b76d63436fc4641dd741678eeb24f15b2f668706dc8512
|
|
7
|
+
data.tar.gz: 4f8eb7a9e0214dcd4e34ce1f08bb8d771c87ad86b428ac5ebe14a26a1bf86502bb6cea8b39d25c14cc475a44e2fdc3d3683d6129e53111599694339fcf03333b
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,56 +1,147 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
<h1 align="center">Thuban</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>A pure Ruby reader for local Git repositories</strong>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/thuban"><img src="https://img.shields.io/gem/v/thuban.svg?colorB=319e8c" alt="Gem Version"></a>
|
|
9
|
+
<a href="https://rubygems.org/gems/thuban"><img src="https://img.shields.io/gem/dt/thuban.svg" alt="Downloads"></a>
|
|
10
|
+
<a href="https://github.com/noxdea/thuban/actions/workflows/main.yml"><img src="https://github.com/noxdea/thuban/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby 3.1+">
|
|
12
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="#features">Features</a> ·
|
|
17
|
+
<a href="#installation">Installation</a> ·
|
|
18
|
+
<a href="#quick-start">Quick Start</a> ·
|
|
19
|
+
<a href="#usage">Usage</a> ·
|
|
20
|
+
<a href="#scope">Scope</a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Thuban is a pure Ruby Git repository reader for objects, packs, index,
|
|
26
|
+
status, blame, and guarded checkout. It works directly with local repository
|
|
27
|
+
data without invoking the Git executable.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Reads loose objects and packfiles, including deltified objects
|
|
32
|
+
- Resolves refs, branches, commits, trees, and blobs
|
|
33
|
+
- Reads the index and reports staged, worktree, and untracked changes
|
|
34
|
+
- Tracks line history across commits and renames with blame
|
|
35
|
+
- Writes files atomically and checks out branches with collision guards
|
|
36
|
+
- Supports linked worktrees and packed refs
|
|
6
37
|
|
|
7
38
|
## Installation
|
|
8
39
|
|
|
40
|
+
Add Thuban to your Gemfile:
|
|
41
|
+
|
|
9
42
|
```ruby
|
|
10
|
-
gem "thuban", "~> 0.
|
|
43
|
+
gem "thuban", "~> 0.2.0"
|
|
11
44
|
```
|
|
12
45
|
|
|
13
|
-
|
|
46
|
+
Then install it:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
bundle install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Thuban requires Ruby 3.1 or later and targets SHA-1 Git repositories.
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
Open the repository containing the current directory:
|
|
14
57
|
|
|
15
58
|
```ruby
|
|
16
59
|
require "thuban"
|
|
17
60
|
|
|
18
61
|
repo = Thuban::Repository.new(Dir.pwd)
|
|
19
|
-
|
|
20
|
-
repo.branch
|
|
62
|
+
|
|
63
|
+
puts repo.branch
|
|
64
|
+
puts repo.head
|
|
65
|
+
|
|
66
|
+
repo.status.each do |entry|
|
|
67
|
+
puts "#{entry.code} #{entry.path}"
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
### Read Repository Data
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
repo.refs
|
|
77
|
+
repo.branches
|
|
78
|
+
repo.commit
|
|
79
|
+
repo.tree
|
|
21
80
|
repo.blob("README.md")
|
|
22
|
-
repo.
|
|
23
|
-
repo.worktree_content("README.md")
|
|
24
|
-
repo.status
|
|
81
|
+
repo.index
|
|
25
82
|
repo.blame("README.md")
|
|
26
83
|
```
|
|
27
84
|
|
|
28
|
-
|
|
85
|
+
Pass a branch, tag, or object ID when reading another revision:
|
|
29
86
|
|
|
30
87
|
```ruby
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
88
|
+
repo.commit("v0.1.0")
|
|
89
|
+
repo.tree("main")
|
|
90
|
+
repo.blob("README.md", reference: "v0.1.0")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Compare and Restore Changes
|
|
94
|
+
|
|
95
|
+
Thuban exposes staged and worktree content for explicit comparison with
|
|
96
|
+
[Porrima](https://github.com/noxdea/porrima):
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
before = repo.staged_blob("README.md").to_s
|
|
100
|
+
after = repo.worktree_content("README.md").to_s
|
|
101
|
+
diff = Porrima.diff(before, after)
|
|
35
102
|
|
|
36
|
-
hunk = diff.hunks.first
|
|
37
|
-
repo.write("README.md", Porrima.revert(
|
|
103
|
+
if (hunk = diff.hunks.first)
|
|
104
|
+
repo.write("README.md", Porrima.revert(after, hunk))
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Write and Check Out
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
repo.write("README.md", "updated contents\n")
|
|
112
|
+
repo.checkout("feature")
|
|
38
113
|
```
|
|
39
114
|
|
|
40
115
|
`Repository#write` rejects symlinks, preserves the file mode, and replaces the
|
|
41
116
|
file atomically. `Repository#checkout` requires a clean tracked worktree and
|
|
42
|
-
aborts on untracked collisions.
|
|
117
|
+
aborts on untracked or ignored collisions.
|
|
118
|
+
|
|
119
|
+
### Match Ignored Paths
|
|
120
|
+
|
|
121
|
+
Load Git's global excludes, `.git/info/exclude`, and nested `.gitignore` files.
|
|
122
|
+
Thuban also reads nested `.ignore` files for editor compatibility:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
ignore = Thuban::IgnoreMatcher.load(Dir.pwd)
|
|
126
|
+
ignore.ignored?("tmp/output.log")
|
|
127
|
+
ignore.ignored?("build", directory: true)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Pass root-relative or absolute files as `extra_files:` to apply them last, or
|
|
131
|
+
set `global: false` to skip `core.excludesFile` and the default global ignore
|
|
132
|
+
file. Thuban resolves the setting from system, XDG, home, and repository config
|
|
133
|
+
files, followed by an enabled worktree config. Included configs, line
|
|
134
|
+
continuations, and command-scoped overrides are not evaluated.
|
|
43
135
|
|
|
44
136
|
## Scope
|
|
45
137
|
|
|
46
|
-
Thuban does not
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
replacement and checkout; it does not rewrite history.
|
|
138
|
+
Thuban does not perform network operations such as fetch or push, rewrite
|
|
139
|
+
history, or provide its own diff algorithm. Blame delegates line matching to
|
|
140
|
+
Porrima through the injectable `differ:` argument.
|
|
50
141
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
142
|
+
Support is limited to the index and pack formats covered by the test suite.
|
|
143
|
+
Submodule checkout and optional Git extensions outside that coverage are not
|
|
144
|
+
supported. Design decisions are recorded in [docs/adr](docs/adr/README.md).
|
|
54
145
|
|
|
55
146
|
## Development
|
|
56
147
|
|
|
@@ -62,6 +153,11 @@ bundle exec rbs -I sig -r porrima validate
|
|
|
62
153
|
gem build --strict thuban.gemspec
|
|
63
154
|
```
|
|
64
155
|
|
|
156
|
+
## Contributing
|
|
157
|
+
|
|
158
|
+
Bug reports and pull requests are welcome on
|
|
159
|
+
[GitHub](https://github.com/noxdea/thuban).
|
|
160
|
+
|
|
65
161
|
## License
|
|
66
162
|
|
|
67
|
-
Thuban is available under the MIT License.
|
|
163
|
+
Thuban is available under the [MIT License](LICENSE.txt).
|
|
@@ -4,6 +4,24 @@ module Thuban
|
|
|
4
4
|
class IgnoreMatcher
|
|
5
5
|
Rule = Struct.new(:base, :expression, :negated, :directory, keyword_init: true)
|
|
6
6
|
|
|
7
|
+
def self.load(root, extra_files: [], global: true)
|
|
8
|
+
root = File.realpath(root)
|
|
9
|
+
raise ArgumentError, "ignore root must be a directory" unless File.directory?(root)
|
|
10
|
+
|
|
11
|
+
extra_files = Array(extra_files)
|
|
12
|
+
matcher = new
|
|
13
|
+
matcher = add_file(matcher, global_ignore_file(root)) if global
|
|
14
|
+
matcher = add_file(matcher, repository_exclude_file(root))
|
|
15
|
+
matcher = discover(root, "", matcher, prune: extra_files.empty?)
|
|
16
|
+
extra_files.each do |file|
|
|
17
|
+
absolute = File.expand_path(file, root)
|
|
18
|
+
relative = absolute.delete_prefix(root + File::SEPARATOR)
|
|
19
|
+
base = absolute == relative ? "" : File.dirname(relative)
|
|
20
|
+
matcher = add_file(matcher, absolute, base == "." ? "" : base)
|
|
21
|
+
end
|
|
22
|
+
matcher
|
|
23
|
+
end
|
|
24
|
+
|
|
7
25
|
def initialize(rules = [])
|
|
8
26
|
@rules = rules.freeze
|
|
9
27
|
end
|
|
@@ -31,15 +49,143 @@ module Thuban
|
|
|
31
49
|
end
|
|
32
50
|
|
|
33
51
|
def ignored?(path, directory: false)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
parts = path.to_s.sub(%r{\A\./}, "").delete_suffix("/").split("/")
|
|
53
|
+
parts.each_index do |index|
|
|
54
|
+
candidate = parts[0..index].join("/")
|
|
55
|
+
candidate_directory = index < parts.length - 1 || directory
|
|
56
|
+
ignored = false
|
|
57
|
+
@rules.each do |rule|
|
|
58
|
+
next if rule.directory && !candidate_directory
|
|
59
|
+
next unless rule.base.empty? || candidate.start_with?(rule.base + "/")
|
|
60
|
+
|
|
61
|
+
local = rule.base.empty? ? candidate : candidate[(rule.base.length + 1)..]
|
|
62
|
+
ignored = !rule.negated if rule.expression.match?(local)
|
|
63
|
+
end
|
|
64
|
+
return true if ignored
|
|
65
|
+
end
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def add_file(matcher, path, base = "")
|
|
73
|
+
path && File.file?(path) ? matcher.add(File.read(path, encoding: "UTF-8"), base: base) : matcher
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def discover(root, directory, matcher, prune:)
|
|
77
|
+
%w[.gitignore .ignore].each do |name|
|
|
78
|
+
path = File.join(root, directory, name)
|
|
79
|
+
matcher = add_file(matcher, path, directory) unless File.symlink?(path)
|
|
80
|
+
end
|
|
81
|
+
Dir.children(File.join(root, directory)).sort.each do |name|
|
|
82
|
+
next if name == ".git"
|
|
83
|
+
|
|
84
|
+
relative = directory.empty? ? name : File.join(directory, name)
|
|
85
|
+
absolute = File.join(root, relative)
|
|
86
|
+
matcher = discover(root, relative, matcher, prune: prune) if File.directory?(absolute) && !File.symlink?(absolute) && (!prune || !matcher.ignored?(relative, directory: true))
|
|
87
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
matcher
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def global_ignore_file(root)
|
|
94
|
+
configured = git_config_files(root).filter_map { |path| excludes_file_from(path) }.last
|
|
95
|
+
return File.expand_path(configured, root) if configured
|
|
96
|
+
|
|
97
|
+
File.join(xdg_config_home, "git", "ignore")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def git_config_files(root)
|
|
101
|
+
files = []
|
|
102
|
+
files << (ENV["GIT_CONFIG_SYSTEM"] || "/etc/gitconfig") unless ENV["GIT_CONFIG_NOSYSTEM"]
|
|
103
|
+
if ENV["GIT_CONFIG_GLOBAL"]
|
|
104
|
+
files << ENV["GIT_CONFIG_GLOBAL"]
|
|
105
|
+
else
|
|
106
|
+
files << File.join(xdg_config_home, "git", "config") << File.join(Dir.home, ".gitconfig")
|
|
107
|
+
end
|
|
108
|
+
if (git_dirs = repository_git_dirs(root))
|
|
109
|
+
worktree_dir, common_dir = git_dirs
|
|
110
|
+
common_config = File.join(common_dir, "config")
|
|
111
|
+
files << common_config
|
|
112
|
+
files << File.join(worktree_dir, "config.worktree") if worktree_config?(common_config)
|
|
113
|
+
end
|
|
114
|
+
files
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def excludes_file_from(path) = config_value(path, "core", "excludesfile")
|
|
118
|
+
|
|
119
|
+
def worktree_config?(path)
|
|
120
|
+
%w[true yes on 1].include?(config_value(path, "extensions", "worktreeconfig").to_s.downcase)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def config_value(path, wanted_section, wanted_key)
|
|
124
|
+
section = nil
|
|
125
|
+
value = nil
|
|
126
|
+
File.foreach(path, encoding: "UTF-8") do |line|
|
|
127
|
+
line = line.strip
|
|
128
|
+
next if line.empty? || line.start_with?("#", ";")
|
|
129
|
+
|
|
130
|
+
if (match = line.match(/\A\[\s*([^\s\]"]+)\s*\]\z/))
|
|
131
|
+
section = match[1].downcase
|
|
132
|
+
elsif section == wanted_section && (match = line.match(/\A#{Regexp.escape(wanted_key)}\s*=\s*(.*)\z/i))
|
|
133
|
+
value = parse_config_value(match[1])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
value
|
|
137
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_config_value(source)
|
|
142
|
+
escapes = {"n" => "\n", "t" => "\t", "b" => "\b", "\\" => "\\", "\"" => "\""}
|
|
143
|
+
value = +""
|
|
144
|
+
significant = 0
|
|
145
|
+
quoted = escaped = false
|
|
146
|
+
source.each_char do |character|
|
|
147
|
+
if escaped
|
|
148
|
+
value << escapes.fetch(character, character)
|
|
149
|
+
significant = value.length
|
|
150
|
+
escaped = false
|
|
151
|
+
elsif character == "\\"
|
|
152
|
+
escaped = true
|
|
153
|
+
elsif character == "\""
|
|
154
|
+
quoted = !quoted
|
|
155
|
+
elsif !quoted && ["#", ";"].include?(character)
|
|
156
|
+
break
|
|
157
|
+
else
|
|
158
|
+
value << character
|
|
159
|
+
significant = value.length if quoted || !character.match?(/\s/)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
value[0, significant]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def xdg_config_home
|
|
166
|
+
value = ENV["XDG_CONFIG_HOME"]
|
|
167
|
+
!value || value.empty? ? File.join(Dir.home, ".config") : value
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def repository_exclude_file(root)
|
|
171
|
+
git_dirs = repository_git_dirs(root)
|
|
172
|
+
File.join(git_dirs[1], "info", "exclude") if git_dirs
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def repository_git_dirs(root)
|
|
176
|
+
git_dir = File.join(root, ".git")
|
|
177
|
+
if File.file?(git_dir)
|
|
178
|
+
value = File.read(git_dir).strip
|
|
179
|
+
return unless value.start_with?("gitdir: ")
|
|
180
|
+
|
|
181
|
+
git_dir = File.expand_path(value.delete_prefix("gitdir: "), root)
|
|
182
|
+
end
|
|
183
|
+
return unless File.directory?(git_dir)
|
|
38
184
|
|
|
39
|
-
|
|
40
|
-
|
|
185
|
+
common = File.join(git_dir, "commondir")
|
|
186
|
+
common_dir = File.file?(common) ? File.expand_path(File.read(common).strip, git_dir) : git_dir
|
|
187
|
+
[git_dir, common_dir]
|
|
41
188
|
end
|
|
42
|
-
ignored
|
|
43
189
|
end
|
|
44
190
|
|
|
45
191
|
private
|
data/lib/thuban/version.rb
CHANGED
data/sig/thuban.rbs
CHANGED
|
@@ -26,6 +26,20 @@ module Thuban
|
|
|
26
26
|
def self.new: (path: String, oid: String, mode: Integer) -> instance
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
class IgnoreMatcher
|
|
30
|
+
class Rule < Struct[untyped]
|
|
31
|
+
attr_accessor base: String
|
|
32
|
+
attr_accessor expression: Regexp
|
|
33
|
+
attr_accessor negated: bool
|
|
34
|
+
attr_accessor directory: bool
|
|
35
|
+
def self.new: (?base: String, ?expression: Regexp, ?negated: bool, ?directory: bool) -> instance
|
|
36
|
+
end
|
|
37
|
+
def self.load: (String root, ?extra_files: Array[String], ?global: bool) -> IgnoreMatcher
|
|
38
|
+
def initialize: (?Array[Rule] rules) -> void
|
|
39
|
+
def add: (String source, ?base: String) -> IgnoreMatcher
|
|
40
|
+
def ignored?: (String path, ?directory: bool) -> bool
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
class Repository
|
|
30
44
|
attr_reader root: String?
|
|
31
45
|
attr_reader git_dir: String
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: thuban
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -68,7 +68,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
68
68
|
- !ruby/object:Gem::Version
|
|
69
69
|
version: '0'
|
|
70
70
|
requirements: []
|
|
71
|
-
rubygems_version:
|
|
71
|
+
rubygems_version: 3.6.9
|
|
72
72
|
specification_version: 4
|
|
73
73
|
summary: A pure Ruby Git repository reader
|
|
74
74
|
test_files: []
|