au 0.1.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.
@@ -0,0 +1,69 @@
1
+ require 'pstore'
2
+
3
+ module Au
4
+ class Diff
5
+
6
+ def self.create(db_name, parent_id, change, md5)
7
+ db = get_db(db_name)
8
+ id = Time.now.to_f
9
+ db.transaction do
10
+ db[id] = [parent_id, change, md5]
11
+ end
12
+ id
13
+ end
14
+
15
+ def self.replay_changes_upto(id, db_name)
16
+ raise 'Missing block' unless block_given?
17
+ diffs = [find(id, db_name: db_name)]
18
+ while diffs.last.has_parent?
19
+ diffs << diffs.last.parent
20
+ end
21
+ # apply the oldest first
22
+ diffs.reverse.each do |diff|
23
+ yield(diff)
24
+ end
25
+ end
26
+
27
+ def self.find(id, db_name: nil, db: nil)
28
+ raise 'Missing both db name and db' if db_name.nil? && db.nil?
29
+ db ||= get_db(db_name)
30
+ stored_values = db.transaction{ db[id] }
31
+
32
+ raise 'Diff not found' unless stored_values
33
+ parent_id, change, md5 = stored_values
34
+ new(id, parent_id, change, md5, db)
35
+ end
36
+
37
+ # memoize all encountered dbs
38
+ # Note, may need to watch memory usage.
39
+ # Note, depending on PStore initialization, may not need this
40
+ def self.get_db(db_name)
41
+ if @dbs
42
+ @dbs[db_name] ||= PStore.new(db_name)
43
+ else
44
+ @dbs = {}
45
+ get_db(db_name)
46
+ end
47
+ end
48
+
49
+ attr_reader :id, :parent_id, :change, :md5, :db
50
+
51
+ def initialize(id, parent_id, change, md5, db)
52
+ @id = id
53
+ @parent_id = parent_id
54
+ @change = change
55
+ @md5 = md5
56
+ @db = db
57
+ end
58
+
59
+ def parent
60
+ return unless parent_id
61
+ self.class.find(parent_id, db: db)
62
+ end
63
+
64
+ def has_parent?
65
+ !!parent_id
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,104 @@
1
+ require 'pstore'
2
+ require 'tempfile'
3
+ require 'digest/md5'
4
+ require 'fileutils'
5
+ require_relative '../models/diff'
6
+
7
+ module Au
8
+ class Document
9
+ class HasMergeConflict < StandardError
10
+ attr_reader :document
11
+ def initialize(document)
12
+ @document = document
13
+ super
14
+ end
15
+ end
16
+
17
+ def self.find(paths)
18
+ paths.is_a?(Array) ? paths.map{ |path| new(path) } : new(paths)
19
+ end
20
+
21
+ attr_reader :path
22
+
23
+ # Do NOT invoke this method directly, always use
24
+ # Document.find instead.
25
+ def initialize(path)
26
+ @path = path
27
+ end
28
+
29
+ def create_diff(parent_diff_id)
30
+ return nil unless File.exists?(abs_path)
31
+ changes = diff(parent_diff_id)
32
+ # if not first diff AND there's no change, don't create new diff
33
+ return parent_diff_id if changes.empty? && parent_diff_id
34
+ Diff.create(diff_db_name, parent_diff_id, changes, calculate_md5)
35
+ end
36
+
37
+ def diff_3(ancestor_diff_id:, other_diff_id:)
38
+ file_in_ancestor_state = content_from(ancestor_diff_id)
39
+ file_in_other_state = content_from(other_diff_id)
40
+ diff3_args = "#{abs_path} #{file_in_ancestor_state.path} #{file_in_other_state.path}"
41
+
42
+ has_conflict = !`diff3 -x #{diff3_args}`.empty?
43
+ merged_content = `diff3 --merge #{diff3_args}`
44
+ File.open(abs_path, 'w'){ |f| f.write(merged_content) }
45
+
46
+ raise HasMergeConflict.new(self) if has_conflict
47
+ end
48
+
49
+ def changed?(parent_diff_id)
50
+ !diff(parent_diff_id).empty?
51
+ end
52
+
53
+ def diff(parent_diff_id)
54
+ `diff -c #{abs_path} #{content_from(parent_diff_id).path}`
55
+ end
56
+
57
+ def content_from(diff_id)
58
+ output = Tempfile.new
59
+ return output unless diff_id
60
+ apply_diffs(output, diff_id)
61
+ output
62
+ end
63
+
64
+ def checkout(diff_id)
65
+ # do nothing if file didn't change
66
+ return if File.exists?(abs_path) && calculate_md5 == md5_at(diff_id)
67
+ FileUtils.mkdir_p(File.dirname(abs_path)) unless File.exists?(abs_path)
68
+
69
+ File.open(abs_path, 'w') do |f|
70
+ apply_diffs(f, diff_id)
71
+ end
72
+ end
73
+
74
+ def md5_at(diff_id)
75
+ Diff.find(diff_id, db_name: diff_db_name).md5
76
+ end
77
+
78
+ private
79
+
80
+ def calculate_md5
81
+ Digest::MD5.hexdigest(File.read(abs_path))
82
+ end
83
+
84
+ def apply_diffs(output, last_diff_id)
85
+ Diff.replay_changes_upto(last_diff_id, diff_db_name) do |diff|
86
+ Tempfile.create do |f|
87
+ f.write(diff.change)
88
+ f.rewind
89
+ `patch -cR #{output.path} #{f.path}`
90
+ end
91
+ end
92
+ end
93
+
94
+ def diff_db_name
95
+ paths = [Repository.path, "diff_#{path.gsub('/', '.')}.pstore"].compact
96
+ File.join(*paths)
97
+ end
98
+
99
+ def abs_path
100
+ File.join(Repository.root, path)
101
+ end
102
+
103
+ end
104
+ end
@@ -0,0 +1,254 @@
1
+ require 'pstore'
2
+
3
+ module Au
4
+ class Repository
5
+ # singleton class
6
+ attr_reader :root, :path, :current, :head
7
+
8
+ def self.root(path = nil)
9
+ instance(path, false).root
10
+ end
11
+
12
+ def self.path(path = nil)
13
+ instance(path, false).path
14
+ end
15
+
16
+ def self.head(path = nil)
17
+ Commit.find instance(path, false).head
18
+ end
19
+
20
+ def self.diff(file_path)
21
+ return nil unless head
22
+ tracked_version = head.cat(file_path)
23
+ return nil unless tracked_version
24
+ `diff -c #{tracked_version.path} #{File.join(root, file_path)}`
25
+ end
26
+
27
+ def self.instance(path = nil, create = false)
28
+ return @single_instance unless @single_instance.nil?
29
+ @single_instance = new(path, create)
30
+ end
31
+
32
+ def initialize(path, create = false, remote_path = nil)
33
+ if path.nil?
34
+ working_dir = Dir.pwd
35
+ # little bit different with mercurial 0.1
36
+ # here we only check whether the current working directory is a repository
37
+ if not File.directory?(File.join(working_dir, '.au')) and not create
38
+ raise 'Repository not found in ' + working_dir
39
+ end
40
+ path = working_dir
41
+ end
42
+
43
+ @root = path
44
+ @path = File.join(path, '.au')
45
+
46
+ Dir.mkdir(@path) if create && !File.directory?(@path)
47
+
48
+ @head_pstore = PStore.new(File.join(@path, 'head.pstore'))
49
+
50
+ update_head_var
51
+
52
+ @alt_pstore = PStore.new(File.join(@path, 'alt.pstore'))
53
+
54
+ update_alt_list
55
+
56
+ @leaves_pstore = PStore.new(File.join(@path, 'leaves.pstore'))
57
+ end
58
+
59
+ def merge(commit_to_merge)
60
+ commit_id = Commit.merge(@head, commit_to_merge)
61
+ return false if commit_id.nil?
62
+ # checkout to new commit
63
+ checkout(commit_id)
64
+ true
65
+ end
66
+
67
+ def commit(commit_message)
68
+ staged_file_paths = Staging.staged_file_paths
69
+ return if staged_file_paths.empty?
70
+ commit_created_id =
71
+ if File.exist?(Repository.resolve_conflict_fname)
72
+ parent_id1, parent_id2 = File.read(Repository.resolve_conflict_fname).split(',')
73
+ File.delete(Repository.resolve_conflict_fname)
74
+ Commit.create_commit_resolve_conflict(staged_file_paths, parent_id1, parent_id2)
75
+ else
76
+ Commit.create(staged_file_paths, commit_message, @head)
77
+ end
78
+ update_head_pstore(commit_created_id)
79
+ update_leaves_pstore(commit_created_id)
80
+ Staging.clear_staged_file_paths
81
+ commit_created_id
82
+ end
83
+
84
+ def checkout(commit_id)
85
+ this_commit = Commit.find(commit_id)
86
+ if this_commit
87
+ this_commit.checkout(update_head_var)
88
+ update_alt_pstore(update_head_var)
89
+ update_head_pstore(commit_id)
90
+ update_head_var
91
+ end
92
+ end
93
+
94
+ def pull(remote_repo)
95
+ # Initialize local and remote db and commit list
96
+ remote_commit_db = Commit.db(remote_repo.root)
97
+ remote_commit_list = remote_commit_db.transaction do
98
+ remote_commit_db.roots
99
+ end
100
+ local_commit_db = Commit.db
101
+ local_commit_list = local_commit_db.transaction do
102
+ local_commit_db.roots
103
+ end
104
+ # Check if the root commits are the same
105
+ if local_commit_list[0] != remote_commit_list[0]
106
+ raise "Error: Root commits do not match."
107
+ end
108
+
109
+ # Find commits that only exist locally
110
+ commit_id_to_add_list = remote_commit_list.reject{|x| local_commit_list.include?(x)}
111
+ commits_to_add = {}
112
+ remote_commit_db.transaction(true) do
113
+ commit_id_to_add_list.each{|commit_id| commits_to_add[commit_id] = remote_commit_db[commit_id]}
114
+ end
115
+
116
+ # Write those commits to remote commit db
117
+ local_commit_db.transaction do
118
+ commits_to_add.each{|commit_id, data| local_commit_db[commit_id] = data}
119
+ end
120
+
121
+ # Find documents and their diff_id in those commits that only existed locally
122
+ remote_diff_id_to_add_list = {}
123
+ commits_to_add.each do |commit|
124
+ commit[:doc_diff_ids].each do |doc_path, diff_id|
125
+ remote_diff_id_to_add_list[doc_path] = diff_id
126
+ end
127
+ end
128
+
129
+ # Write these information to remote
130
+ remote_diff_id_to_add_list.each do |doc_path, diff_id|
131
+ diff_pstore_remote = PStore.new(diff_db_name_remote(doc_path, remote_repo.path))
132
+ diff_pstore_remote.transaction(true) do
133
+ diff_pstore_local = PStore.new(diff_db_name_remote(doc_path, @path))
134
+ diff_pstore_local.transaction do
135
+ if diff_pstore_local[doc_path] == nil
136
+ diff_pstore_local[doc_path] = diff_pstore_remote[doc_path]
137
+ elsif diff_pstore_local[doc_path] != diff_pstore_remote[doc_path]
138
+ raise "Error: Remote and local reposiroty have different content in a same diff"
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ def push(remote_repo)
146
+ # Initialize local and remote db and commit list
147
+ remote_commit_db = Commit.db(remote_repo.root)
148
+ remote_commit_list = remote_commit_db.transaction do
149
+ remote_commit_db.roots
150
+ end
151
+ local_commit_db = Commit.db
152
+ local_commit_list = local_commit_db.transaction do
153
+ local_commit_db.roots
154
+ end
155
+ # Check if the root commits are the same
156
+ if local_commit_list[0] != remote_commit_list[0]
157
+ raise "Error: Root commits do not match."
158
+ end
159
+
160
+ # Find commits that only exist locally
161
+ commit_id_to_add_list = local_commit_list.reject{|x| remote_commit_list.include?(x)}
162
+ commits_to_add = {}
163
+ remote_commit_db.transaction(true) do
164
+ commit_id_to_add_list.each{|commit_id| commits_to_add[commit_id] = remote_commit_db[commit_id]}
165
+ end
166
+
167
+ # Write those commits to remote commit db
168
+ remote_commit_db.transaction do
169
+ commits_to_add.each{|commit_id, data| remote_commit_db[commit_id] = data}
170
+ end
171
+
172
+ # Find documents and their diff_id in those commits that only existed locally
173
+ local_diff_id_to_add_list = {}
174
+ commits_to_add.each do |commit|
175
+ commit[:doc_diff_ids].each do |doc_path, diff_id|
176
+ local_diff_id_to_add_list[doc_path] = diff_id
177
+ end
178
+ end
179
+
180
+ # Write these information to remote
181
+ local_diff_id_to_add_list.each do |doc_path, diff_id|
182
+ diff_pstore_local = PStore.new(diff_db_name_remote(doc_path, @path))
183
+ diff_pstore_local.transaction(true) do
184
+ diff_pstore_remote = PStore.new(diff_db_name_remote(doc_path, remote_repo.path))
185
+ diff_pstore_remote.transaction do
186
+ if diff_pstore_remote[doc_path] == nil
187
+ diff_pstore_remote[doc_path] = diff_pstore_local[doc_path]
188
+ elsif diff_pstore_local[doc_path] != diff_pstore_remote[doc_path]
189
+ raise "Error: Remote and local reposiroty have different content in a same diff"
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ # def clone
197
+ #
198
+ # end
199
+
200
+ def heads
201
+ @leaves_pstore.transaction(true) do
202
+ @leaves_pstore.roots
203
+ end
204
+ end
205
+
206
+ private
207
+
208
+ def diff_db_name_remote(file_path, remote_path)
209
+ paths = [Repository(remote_path, false).path, "diff_#{file_path.gsub('/', '.')}.pstore"].compact
210
+ File.join(*paths)
211
+ end
212
+
213
+
214
+ def update_head_var
215
+ @head = @head_pstore.transaction(true) do
216
+ @head_pstore[:head]
217
+ end
218
+ end
219
+
220
+ def update_head_pstore(commit_id)
221
+ @head = @head_pstore.transaction do
222
+ @head_pstore[:head] = commit_id
223
+ end
224
+ end
225
+
226
+ def update_alt_list
227
+ @alt_list = @alt_pstore.transaction(true) do
228
+ @alt_pstore[:commit]
229
+ end
230
+ end
231
+
232
+ def update_alt_pstore(commit_id)
233
+ @alt_pstore.transaction do
234
+ @alt_pstore[:commit].nil? ? (@alt_pstore[:commit] = [commit_id]) : (@alt_pstore[:commit] << commit_id)
235
+ end
236
+ end
237
+
238
+ def update_leaves_pstore(commit_id)
239
+ @leaves_pstore.transaction do
240
+ commit = Commit.find(commit_id)
241
+ if commit
242
+ parent_commit_id_1, parent_commit_id_2 = commit.parent_id_1, commit.parent_id_2
243
+ @leaves_pstore.delete(parent_commit_id_1)
244
+ @leaves_pstore.delete(parent_commit_id_2)
245
+ @leaves_pstore[commit_id] = 1
246
+ end
247
+ end
248
+ end
249
+
250
+ def self.resolve_conflict_fname
251
+ File.join(Repository.instance.path, 'resolve_conflict')
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,91 @@
1
+ require 'digest/md5'
2
+ require 'pstore'
3
+
4
+ module Au
5
+ class Staging
6
+
7
+ def self.status(last_commit = Repository.head)
8
+ return project_file_paths unless last_commit
9
+ changed_doc_paths = []
10
+ tracked_docs_md5 = last_commit.tracked_docs_md5
11
+ project_file_paths.each do |relative_path|
12
+ known_md5 = tracked_docs_md5[relative_path]
13
+ # file not tracked or tracked but hasn't changed
14
+ if known_md5.nil? || Digest::MD5.hexdigest(File.read(abs_path(relative_path))) != known_md5
15
+ changed_doc_paths << relative_path
16
+ end
17
+ tracked_docs_md5.delete(relative_path) if known_md5
18
+ end
19
+
20
+ # remaining files in tracked_docs_md5 are no longer in the project
21
+ changed_doc_paths += tracked_docs_md5.keys unless tracked_docs_md5.empty?
22
+ changed_doc_paths
23
+ end
24
+
25
+ def self.stage(relative_paths, last_commit = Repository.head)
26
+ relative_paths = relative_paths.each_with_object([]) do |staged_path, accum|
27
+ if File.directory?(staged_path)
28
+ Dir.glob('**/*', base: staged_path) do |sub_path|
29
+ accum << (File.join(staged_path, sub_path)) unless File.directory?(sub_path)
30
+ end
31
+ else
32
+ accum << staged_path
33
+ end
34
+ end
35
+
36
+ stage_db.transaction do
37
+ filter_stageable(last_commit, relative_paths).each do |relative_path|
38
+ stage_db[relative_path] = 1
39
+ end
40
+ end
41
+ relative_paths
42
+ end
43
+
44
+ def self.filter_stageable(last_commit, relative_paths)
45
+ tracked_docs_md5 = last_commit&.tracked_docs_md5 || {}
46
+ relative_paths.reject do |relative_path|
47
+ known_md5 = tracked_docs_md5[relative_path]
48
+ abs_path = File.join(Repository.root, relative_path)
49
+ if File.exists?(abs_path)
50
+ known_md5 && Digest::MD5.hexdigest(File.read(abs_path)) == known_md5
51
+ else
52
+ known_md5.nil?
53
+ end
54
+ end
55
+ end
56
+
57
+ def self.staged_file_paths
58
+ stage_db.transaction(true) do
59
+ stage_db.roots
60
+ end
61
+ end
62
+
63
+ def self.clear_staged_file_paths
64
+ File.delete stage_db.path
65
+ end
66
+
67
+ def self.unstage(file_paths)
68
+ stage_db.transaction do
69
+ file_paths.each do |file_path|
70
+ stage_db.delete(file_path)
71
+ end
72
+ end
73
+ end
74
+
75
+ def self.abs_path(relative_path)
76
+ File.join(Repository.root, relative_path)
77
+ end
78
+
79
+ def self.project_file_paths
80
+ Dir.glob('**/*', base: Repository.root).reject do |file_path|
81
+ File.directory? file_path
82
+ end
83
+ end
84
+
85
+ # private
86
+ def self.stage_db
87
+ @stage_db ||= PStore.new(File.join(*[Repository.path, 'stage.pstore'].compact))
88
+ end
89
+
90
+ end
91
+ end