au 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +209 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/Rakefile +6 -0
- data/au.gemspec +40 -0
- data/bin/au +170 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/au.rb +14 -0
- data/lib/au/misc/load_pstore.rb +13 -0
- data/lib/au/models/commit.rb +259 -0
- data/lib/au/models/diff.rb +69 -0
- data/lib/au/models/document.rb +104 -0
- data/lib/au/models/repository.rb +254 -0
- data/lib/au/models/staging.rb +91 -0
- data/lib/au/patches/object.rb +12 -0
- data/lib/au/version.rb +3 -0
- metadata +155 -0
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
data/lib/au.rb
ADDED
@@ -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
|