au 0.1.0

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