au 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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