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.
data/bin/au ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'fileutils'
5
+ require 'au'
6
+ require 'thor'
7
+ require 'colorize'
8
+
9
+ module Au
10
+ class CLI < Thor
11
+
12
+ desc 'reset', 'reset .au directory'
13
+ def reset
14
+ FileUtils.rm_r('.au') if File.directory?('.au')
15
+ end
16
+
17
+ desc "init", "Start tracking current directory"
18
+ def init
19
+ Repository.instance(nil, true)
20
+ puts 'Welcome to AU. Your project is now tracked.'
21
+ end
22
+
23
+ desc "clone remote PATH and target PATH", "Copy an existing repo"
24
+ def clone(remote_repo_root, target_repo_root = nil)
25
+ # default target path is the same with rename's name in current working directory
26
+ target_repo_root = File.join(Dir.pwd, File.basename(remote_repo_root)) if target_repo_root.nil?
27
+ if Dir.exist?(target_repo_root)
28
+ puts 'target directory path exists, please try another one'
29
+ return
30
+ end
31
+
32
+ remote_au_dir = File.join(remote_repo_root, '.au')
33
+ unless Dir.exist?(remote_au_dir)
34
+ puts 'given remote directory path is not an Au repo, please try another one.'
35
+ return
36
+ end
37
+
38
+ # create local repo
39
+ Dir.mkdir(target_repo_root)
40
+ target_repo_au_path = File.join(target_repo_root, '.au')
41
+ Dir.mkdir(target_repo_au_path)
42
+ # copy remote's .au directory to local repo
43
+ FileUtils.copy_entry(remote_au_dir, target_repo_au_path)
44
+
45
+ # create current repo's single instance
46
+ Repository.instance(target_repo_root)
47
+
48
+ remote_repo = Au::Repository.new(remote_repo_root, false)
49
+ remote_head_commit_id = remote_repo.update_head_var # get remote head commit id
50
+ if remote_head_commit_id
51
+ cur_repo = Au::Repository.new(target_repo_root, false)
52
+ cur_repo.checkout(remote_head_commit_id) # checkout to remote's head
53
+ end
54
+ end
55
+
56
+ desc "stage PATH", "Stage file for commit"
57
+ def stage(*file_paths)
58
+ puts Staging.stage(file_paths)
59
+ end
60
+
61
+ desc "unstage PATHS", "Unstage file for commit"
62
+ def unstage(*file_path)
63
+ Staging.unstage(file_path)
64
+ end
65
+
66
+ desc "status", "Check current status of repo"
67
+ def status
68
+ changed_files = Staging.status
69
+ staged_files = Staging.staged_file_paths
70
+
71
+ puts ''
72
+ if head_id = Repository.head&.id
73
+ puts " HEAD: #{head_id}".light_green
74
+ else
75
+ puts " No past commit found."
76
+ end
77
+ puts ''
78
+
79
+ puts 'Staged files:' if staged_files.any?
80
+ staged_files.each{ |file_path| puts " " + file_path.green }
81
+
82
+ unstaged_files = changed_files - staged_files
83
+ puts 'Unstaged files:' if unstaged_files.any?
84
+ unstaged_files.each{ |file_path| puts " " + file_path.light_red }
85
+ end
86
+
87
+ desc "heads", "Show current HEAD"
88
+ def heads
89
+ Repository.instance.heads
90
+ end
91
+
92
+ desc "diff PATH", "Display between tracked version and current version"
93
+ def diff(file_path)
94
+ diff = Repository.diff(file_path)
95
+ if diff.present?
96
+ puts diff
97
+ else
98
+ puts "No change found in #{file_path}"
99
+ end
100
+ end
101
+
102
+ desc "checkout COMMIT", "Check out a commit"
103
+ def checkout(commit_id)
104
+ Repository.instance.checkout(commit_id)
105
+ end
106
+
107
+ desc "cat COMMIT PATH", "Display file from provided commit"
108
+ def cat(commit_id, file_path)
109
+ output_tempfile = Commit.find(commit_id).cat(file_path)
110
+
111
+ if output_tempfile
112
+ system("less #{output_tempfile.path}")
113
+ else
114
+ puts "#{commit_id} does not track file #{file_path}"
115
+ end
116
+ end
117
+
118
+ desc "commit", "Commit staged files"
119
+ option :m
120
+ def commit
121
+ created_commit_id = Repository.instance.commit(options[:m])
122
+ if created_commit_id
123
+ puts "Created commit #{created_commit_id}"
124
+ else
125
+ puts 'Please stage your changes first.'
126
+ end
127
+ end
128
+
129
+ desc "log", "Display parent commits"
130
+ def log
131
+ Repository.head.log.each do |commit|
132
+ commit.id == Repository.head.id ? puts("#{commit.id}".green) : puts(commit.id)
133
+ puts commit.message
134
+ puts
135
+ end
136
+ end
137
+
138
+ desc "merge COMMIT", "Merge with provided commit"
139
+ def merge(commit_id)
140
+ Repository.instance.merge(commit_id)
141
+ end
142
+
143
+ desc "pull REMOTE_PATH", "Pull changes from remote repo"
144
+ def pull(remote_repo_root)
145
+ remote = nil
146
+ begin
147
+ remote = Repository.instance(remote_repo_root, false)
148
+ rescue
149
+ puts "Remote path is invalid!"
150
+ return
151
+ end
152
+ Repository.instance.pull(remote)
153
+ end
154
+
155
+ desc "push REMOTE_PATH", "Push changes to remote repo"
156
+ def push(remote_repo_root)
157
+ remote = nil
158
+ begin
159
+ remote = Repository.instance(remote_repo_root, false)
160
+ rescue
161
+ puts "Remote path is invalid!"
162
+ return
163
+ end
164
+ Repository.instance.push(remote)
165
+ end
166
+
167
+ end
168
+ end
169
+
170
+ Au::CLI.start
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "au"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/au.rb ADDED
@@ -0,0 +1,14 @@
1
+ require 'au/patches/object'
2
+
3
+ require 'au/version'
4
+
5
+ require 'au/models/commit'
6
+ require 'au/models/diff'
7
+ require 'au/models/document'
8
+ require 'au/models/repository'
9
+ require 'au/models/staging'
10
+
11
+ require 'pry'
12
+
13
+ module Au
14
+ end
@@ -0,0 +1,13 @@
1
+ require 'PStore'
2
+
3
+ root = 'test_tmp/repo1/.au'
4
+
5
+ commits_path = File.join(root, 'commits.pstore')
6
+
7
+ commit_store = PStore.new(commits_path)
8
+ commit_store.transaction do
9
+ commit_store.roots.each do |key|
10
+ puts 'key: ', key
11
+ puts 'value: ', commit_store[key]
12
+ end
13
+ end
@@ -0,0 +1,259 @@
1
+ require 'pstore'
2
+ require 'set'
3
+ require 'digest/md5'
4
+ require_relative '../models/document'
5
+
6
+
7
+ # This represents a global commit created when
8
+ # user enters git commit
9
+ module Au
10
+ class Commit
11
+ attr_reader :id, :message, :doc_diff_ids, :parent_id_1, :parent_id_2, :created_at
12
+
13
+ def initialize(id, message, doc_diff_ids, parent_id_1 = nil, parent_id_2 = nil, created_at = 0)
14
+ @id = id
15
+ @message = message
16
+ @doc_diff_ids = doc_diff_ids
17
+ @parent_id_1 = parent_id_1
18
+ @parent_id_2 = parent_id_2
19
+ @created_at = created_at
20
+ end
21
+
22
+ def self.list
23
+ db.transaction(true) do
24
+ puts db.roots
25
+ end
26
+ end
27
+
28
+ # find a commit which has been created
29
+ def self.find(commit_id)
30
+ return unless commit_id # used for initial commit
31
+ db.transaction(true) do
32
+ init_attrs = %i(message doc_diff_ids parent_id_1 parent_id_2 created_at)
33
+ new(commit_id, *db[commit_id].values_at(*init_attrs))
34
+ end
35
+ end
36
+
37
+ # find a remote commit which has been created
38
+ def self.find_remote(commit_id, repo_path)
39
+ return unless commit_id # used for initial commit
40
+ db(repo_path).transaction(true) do
41
+ init_attrs = %i(message doc_diff_ids parent_id_1 parent_id_2 created_at)
42
+ new(commit_id, *db(repo_path)[commit_id].values_at(*init_attrs))
43
+ end
44
+ end
45
+
46
+ def self.create(staged_file_paths, commit_message, parent_commit_id = nil)
47
+ # only support one parent
48
+ # file_paths: changed / added / removed files_paths
49
+ parent_commit = find(parent_commit_id)
50
+ new_doc_diff_ids = {}
51
+
52
+ Document.find(staged_file_paths).each do |doc|
53
+ # pass in the diff id for this doc from parent commit, if present
54
+ diff_id = doc.create_diff(parent_commit ? parent_commit.doc_diff_ids[doc.path] : nil)
55
+ new_doc_diff_ids[doc.path] = diff_id ? diff_id : -1
56
+ end
57
+
58
+ if parent_commit
59
+ docs_from_parent_commit = Document.find(parent_commit.doc_diff_ids.keys)
60
+ docs_from_parent_commit.each do |doc|
61
+ new_doc_diff_ids[doc.path] ||= parent_commit.doc_diff_ids[doc.path]
62
+ end
63
+ end
64
+
65
+ new_commit_id = build_commit_id
66
+ db.transaction do
67
+ db[new_commit_id] = {
68
+ message: commit_message,
69
+ parent_id_1: parent_commit_id,
70
+ doc_diff_ids: new_doc_diff_ids.delete_if{ |key, val| val == -1 },
71
+ created_at: Time.now.to_f
72
+ }
73
+ end
74
+ new_commit_id
75
+ end
76
+
77
+ def self.merge(parent_commit_id_1, parent_commit_id_2)
78
+ # If commit 2 is commit 1's ancestor, don't merge
79
+ if gen_ancestor_set(parent_commit_id_1, Set.new).include?(parent_commit_id_2)
80
+ puts 'Stop merging. Commit to be merged is ancestor of current commit'
81
+ return nil
82
+ end
83
+
84
+ # Load commit objects
85
+ parent1_commit = find(parent_commit_id_1)
86
+ parent2_commit = find(parent_commit_id_2)
87
+ ancestor_commit_id = lowest_common_ancestor(parent_commit_id_1, parent_commit_id_2)
88
+ ancestor_commit = find(ancestor_commit_id)
89
+
90
+ # Initialize local variables
91
+ new_doc_diff_ids = {}
92
+ conflict_file_paths = []
93
+
94
+ # Go through files in commit 1
95
+ parent1_commit.doc_diff_ids.each do |doc_path, diff1_id|
96
+ if parent2_commit.doc_diff_ids[doc_path].nil?
97
+ # If this file only exists in commit 1, add it to the file table
98
+ new_doc_diff_ids[doc_path] = diff1_id
99
+ next
100
+ end
101
+
102
+ doc = Document.find(doc_path)
103
+ begin
104
+ # If this file also exists in commit 2, run diff3 to merge files and check for conflict.
105
+ doc.diff_3(ancestor_diff_id: ancestor_commit.doc_diff_ids[doc_path], other_diff_id: parent2_commit.doc_diff_ids[doc_path])
106
+ new_doc_diff_ids[doc_path] = doc.create_diff(diff1_id) if conflict_file_paths.empty?
107
+ rescue Document::HasMergeConflict
108
+ conflict_file_paths << doc.path
109
+ end
110
+ end
111
+
112
+ unless conflict_file_paths.empty?
113
+ # add a file to indicate user is resolving conflicts
114
+ File.open(Repository.resolve_conflict_fname, 'w') do |f|
115
+ f.truncate(0)
116
+ f.write(parent_commit_id_1.to_s + ',' + parent_commit_id_2.to_s)
117
+ end
118
+
119
+ puts "Merge failed due to conflicts. Fix conflicts in files: \n#{conflict_file_paths.join('\n')}"
120
+ return nil
121
+ end
122
+
123
+ # Check if there is any file in commit 2 and not in commit 1,
124
+ # if so, add them to file table
125
+ parent2_commit.doc_diff_ids.each do |doc_path, diff2_id|
126
+ new_doc_diff_ids[doc_path] = diff2_id unless parent1_commit.doc_diff_ids.key?(doc_path)
127
+ end
128
+
129
+ # Generate commit id and Write commit
130
+ new_commit_id = build_commit_id
131
+ db.transaction do
132
+ db[new_commit_id] = {
133
+ message: "Merged #{parent_commit_id_1} and #{parent_commit_id_2}",
134
+ parent_id_1: parent_commit_id_1,
135
+ parent_id_2: parent_commit_id_2,
136
+ doc_diff_ids: new_doc_diff_ids,
137
+ created_at: Time.now.to_f
138
+ }
139
+ end
140
+ new_commit_id
141
+ end
142
+
143
+ def self.create_commit_resolve_conflict(staged_file_paths, parent_commit_id_1, parent_commit_id_2)
144
+ parent_commit_1 = find(parent_commit_id_1)
145
+ new_doc_diff_ids = {}
146
+
147
+ Document.find(staged_file_paths).each do |doc|
148
+ # pass in the diff id for this doc from parent commit, if present
149
+ diff_id = doc.create_diff(parent_commit_1 ? parent_commit_1.doc_diff_ids[doc.path] : nil)
150
+ new_doc_diff_ids[doc.path] = diff_id ? diff_id : -1
151
+ end
152
+
153
+ if parent_commit_1
154
+ docs_from_parent_commit_1 = Document.find(parent_commit_1.doc_diff_ids.keys)
155
+ docs_from_parent_commit_1.each do |doc|
156
+ new_doc_diff_ids[doc.path] ||= parent_commit_1.doc_diff_ids[doc.path]
157
+ end
158
+ end
159
+
160
+ new_commit_id = build_commit_id
161
+ db.transaction do
162
+ db[new_commit_id] = {
163
+ message: "Resolved conflicts for #{parent_commit_id_1} and #{parent_commit_id_2}",
164
+ parent_id_1: parent_commit_id_1,
165
+ parent_id_2: parent_commit_id_2,
166
+ doc_diff_ids: new_doc_diff_ids.delete_if{ |key, val| val == -1 },
167
+ created_at: Time.now.to_f
168
+ }
169
+ end
170
+ new_commit_id
171
+ end
172
+
173
+ def checkout(current_commit_id)
174
+ current_commit = Commit.find(current_commit_id)
175
+ @doc_diff_ids.each{ |doc_path, diff_id| Document.find(doc_path).checkout(diff_id) }
176
+ current_commit.doc_diff_ids.each_key do |doc_path|
177
+ abs_doc_path = File.join(Repository.root, doc_path)
178
+ abs_dir_path = File.dirname(abs_doc_path)
179
+ File.delete(abs_doc_path) if not @doc_diff_ids.include?(doc_path) and File.exist?(abs_doc_path)
180
+ Dir.delete(abs_dir_path) if Dir.empty?(abs_dir_path)
181
+ end
182
+ end
183
+
184
+ def self.lowest_common_ancestor(parent_commit_1, parent_commit_2)
185
+ # keys are commit id, values are Array of number of hops
186
+ all_ancestors_of_parent_1 = self.gen_ancestor_set(parent_commit_1, Set.new, skip_first=true)
187
+ all_ancestors_of_parent_2 = self.gen_ancestor_set(parent_commit_2, Set.new, skip_first=true)
188
+
189
+ common_ancestors = all_ancestors_of_parent_1 & all_ancestors_of_parent_2
190
+ raise 'no common ancestors' if common_ancestors.size.zero?
191
+
192
+ while common_ancestors.size != 1
193
+ picked_ancestor = common_ancestors.first
194
+ ancestors_to_be_removed_set = gen_ancestor_set(picked_ancestor, Set.new, skip_first=true)
195
+ common_ancestors -= ancestors_to_be_removed_set
196
+ end
197
+
198
+ common_ancestors.first
199
+ end
200
+
201
+ # returns a tempfile with the content of the document at the recorded diff of commit.
202
+ def cat(file_path)
203
+ diff_id = doc_diff_ids[file_path]
204
+ return unless diff_id
205
+ Document.find(file_path).content_from(diff_id)
206
+ end
207
+
208
+ # Returns an array of ids, starting from self.id and
209
+ # through all left parents (parent 1).
210
+ def log
211
+ commits = [self]
212
+ this_commit = self
213
+ while this_commit.has_parent_1?
214
+ commits << this_commit.parent_1
215
+ this_commit = this_commit.parent_1
216
+ end
217
+ commits
218
+ end
219
+
220
+ def has_parent_1?
221
+ !!parent_id_1
222
+ end
223
+
224
+ def parent_1
225
+ return nil unless parent_id_1
226
+ @parent ||= self.class.find(parent_id_1)
227
+ end
228
+
229
+ def tracked_docs_md5
230
+ Document.find(doc_diff_ids.keys).each_with_object({}) do |doc, accum|
231
+ accum[doc.path] = doc.md5_at(doc_diff_ids[doc.path])
232
+ end
233
+ end
234
+
235
+ private
236
+
237
+ def self.db(repo_path = nil)
238
+ # in this pstore file, each key is a commit id
239
+ @db ||= PStore.new(File.join(Repository.path(repo_path), 'commits.pstore'))
240
+ end
241
+
242
+ def self.build_commit_id
243
+ Digest::MD5.hexdigest(Time.now.to_f.to_s)
244
+ end
245
+
246
+ def self.gen_ancestor_set(commit_id, commit_id_set, skip_first=false)
247
+ commit_id_set.add(commit_id) unless skip_first
248
+ commit = find(commit_id)
249
+
250
+ if commit.parent_id_1
251
+ commit_id_set = self.gen_ancestor_set(commit.parent_id_1, commit_id_set)
252
+ end
253
+ if commit.parent_id_2
254
+ commit_id_set = self.gen_ancestor_set(commit.parent_id_2, commit_id_set)
255
+ end
256
+ commit_id_set
257
+ end
258
+ end
259
+ end