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.
- 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
|