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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41d8c298a842a358db7f9ec204f0b25eb9a11c6b1baa1ac7206f230d148c9874
4
- data.tar.gz: 3ca82e618bf82cc6f40851ac4bd230179a242dc1ba96059379e42e3c4826e6ef
3
+ metadata.gz: f141979f71fdcf5cb0cc8b6678e084569285d9d3ee9f21bf2e584dbeb98a9139
4
+ data.tar.gz: 9fbdf6deec48a31479b5d2b12f564beeaac8cde969d9f50a68f476d89011e59d
5
5
  SHA512:
6
- metadata.gz: 121c47d02bb849760a8e7ca06c8a272dc29bcdc7165401c43064146950207f763dd141118dcfdeb279baa1cc80e02d79a9086077c788ff7a2be9704aa451affe
7
- data.tar.gz: e1bc999f1a766a6ddc7602c6aae005ea5fc5a90530c2646820b9b9b66f3dad0440dc6fe0fd117b31a6e0410b5fe9126d1a96cb287b7e0524ffb6b21154b61059
6
+ metadata.gz: b98d9e65ebe84062f9960c19557a871a80344bcbdd9c9bbb77ae9ffe8e30579464249be003a6f26831b76d63436fc4641dd741678eeb24f15b2f668706dc8512
7
+ data.tar.gz: 4f8eb7a9e0214dcd4e34ce1f08bb8d771c87ad86b428ac5ebe14a26a1bf86502bb6cea8b39d25c14cc475a44e2fdc3d3683d6129e53111599694339fcf03333b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-09-14
4
+
5
+ - Add `Thuban::IgnoreMatcher.load` with Git-compatible ignore source precedence
6
+ - Publish the ignore matcher API and type signatures
7
+
3
8
  ## 0.1.0 - 2026-09-12
4
9
 
5
- - Initial release
10
+ - Initial release
data/README.md CHANGED
@@ -1,56 +1,147 @@
1
- # Thuban
2
-
3
- Thuban is a pure Ruby reader for local Git repositories. It reads SHA-1
4
- objects, packfiles, refs, commits, trees, blobs, indexes, status, and blame,
5
- and supports a guarded checkout.
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.1.0"
43
+ gem "thuban", "~> 0.2.0"
11
44
  ```
12
45
 
13
- ## Usage
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
- repo.head
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.staged_blob("README.md")
23
- repo.worktree_content("README.md")
24
- repo.status
81
+ repo.index
25
82
  repo.blame("README.md")
26
83
  ```
27
84
 
28
- Text comparison stays explicit:
85
+ Pass a branch, tag, or object ID when reading another revision:
29
86
 
30
87
  ```ruby
31
- diff = Porrima.diff(
32
- repo.staged_blob("README.md").to_s,
33
- repo.worktree_content("README.md").to_s
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(repo.worktree_content("README.md"), hunk)) if hunk
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 implement a diff algorithm; blame delegates line matching to
47
- Porrima through the injectable `differ:` argument. It does not perform network
48
- operations such as fetch or push. Writes are limited to atomic worktree
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
- Thuban targets SHA-1 repositories. It supports the index and pack formats
52
- covered by its test suite, not every optional Git extension. Submodule checkout
53
- is deliberately excluded.
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
- ignored = false
35
- @rules.each do |rule|
36
- next if rule.directory && !directory
37
- next unless rule.base.empty? || path.start_with?(rule.base + "/")
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
- local = rule.base.empty? ? path : path[(rule.base.length + 1)..]
40
- ignored = !rule.negated if rule.expression.match?(local)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Thuban
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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: 4.0.19
71
+ rubygems_version: 3.6.9
72
72
  specification_version: 4
73
73
  summary: A pure Ruby Git repository reader
74
74
  test_files: []