Merge lp:~jameinel/bzr/2.0-40412-show-base-weave into lp:bzr

Proposed by John A Meinel
Status: Merged
Approved by: Vincent Ladeuil
Approved revision: not available
Merged at revision: not available
Proposed branch: lp:~jameinel/bzr/2.0-40412-show-base-weave
Merge into: lp:bzr
Diff against target: 514 lines (+341/-39)
6 files modified
bzrlib/merge.py (+36/-24)
bzrlib/tests/blackbox/test_merge.py (+16/-0)
bzrlib/tests/test_merge.py (+112/-0)
bzrlib/tests/test_merge_core.py (+46/-14)
bzrlib/versionedfile.py (+55/-1)
plan_to_base.py (+76/-0)
To merge this branch: bzr merge lp:~jameinel/bzr/2.0-40412-show-base-weave
Reviewer Review Type Date Requested Status
Vincent Ladeuil Needs Fixing
Review via email: mp+15835@code.launchpad.net
To post a comment you must log in.
Revision history for this message
John A Meinel (jameinel) wrote :

This is a fairly small change to the merge code, such that 'bzr merge --weave' and --lca now put a .BASE file on disk along with the .THIS and .OTHER files.

The code is based off of the 2.0 branch, but for now I'm only proposing it for the 2.1 series. Adding a .BASE file on disk is a bit of a 'feature' change. Though not terribly so (given that 'bzr revert' will still clean them up, etc.) The api changes are generally internal functions with loose coupling, so should be ok.

I should also note that I don't have a NEWS entry, because it is based on the 2.0 branch (and thus I don't have a place to put NEWS for the 2.1 series.) I'll add it at merge time.

The basics here are that we walk the 'merge plan' and for each line status, we decide whether that means the content was in a hypothetical BASE. (eg, 'new-a' is newly added in one target, so obviously not in base, 'unchanged' is clearly in BASE, etc.)

In my testing, it gives the same results as a 3-way merge BASE would, except you are likely to use it in cases where 3-way merging doesn't quite cut it.

This code also suffers from:
https://bugs.edge.launchpad.net/bzr/+bug/494197

But I figure, better to land what I've got than try to track down all the edge cases and end up having a huge patch that nobody wants to review. :)

Revision history for this message
Vincent Ladeuil (vila) wrote :

Mentioning https://bugs.edge.launchpad.net/bzr/+bug/40412 and https://bugs.edge.launchpad.net/bzr/+bug/494204 here as courtesy URLs for those watching from home ;)

Do you really intend to land plan_to_base.py ?
If that's the case, contrib may be a more appropriate place no ?
Or did you just forget to delete it before submission (the code looks
like a preliminary version of your patch).

I don't know what happened here but lp is showing conflicts in the diff... which are not there when I review locally on my machine... tricked by the markers in the diff itself ? Weird.

Anyway, if plan_to_base.py is spurious, you can land without it.

review: Needs Fixing

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bzrlib/merge.py'
2--- bzrlib/merge.py 2009-12-03 02:24:54 +0000
3+++ bzrlib/merge.py 2009-12-08 21:10:33 +0000
4@@ -1407,60 +1407,69 @@
5 supports_reverse_cherrypick = False
6 history_based = True
7
8- def _merged_lines(self, file_id):
9- """Generate the merged lines.
10- There is no distinction between lines that are meant to contain <<<<<<<
11- and conflicts.
12- """
13- if self.cherrypick:
14- base = self.base_tree
15- else:
16- base = None
17- plan = self.this_tree.plan_file_merge(file_id, self.other_tree,
18+ def _generate_merge_plan(self, file_id, base):
19+ return self.this_tree.plan_file_merge(file_id, self.other_tree,
20 base=base)
21+
22+ def _merged_lines(self, file_id):
23+ """Generate the merged lines.
24+ There is no distinction between lines that are meant to contain <<<<<<<
25+ and conflicts.
26+ """
27+ if self.cherrypick:
28+ base = self.base_tree
29+ else:
30+ base = None
31+ plan = self._generate_merge_plan(file_id, base)
32 if 'merge' in debug.debug_flags:
33 plan = list(plan)
34 trans_id = self.tt.trans_id_file_id(file_id)
35 name = self.tt.final_name(trans_id) + '.plan'
36- contents = ('%10s|%s' % l for l in plan)
37+ contents = ('%11s|%s' % l for l in plan)
38 self.tt.new_file(name, self.tt.final_parent(trans_id), contents)
39+<<<<<<< TREE
40 textmerge = versionedfile.PlanWeaveMerge(plan, '<<<<<<< TREE\n',
41 '>>>>>>> MERGE-SOURCE\n')
42 return textmerge.merge_lines(self.reprocess)
43+=======
44+ textmerge = PlanWeaveMerge(plan, '<<<<<<< TREE\n',
45+ '>>>>>>> MERGE-SOURCE\n')
46+ lines, conflicts = textmerge.merge_lines(self.reprocess)
47+ if conflicts:
48+ base_lines = textmerge.base_from_plan()
49+ else:
50+ base_lines = None
51+ return lines, base_lines
52+>>>>>>> MERGE-SOURCE
53
54 def text_merge(self, file_id, trans_id):
55 """Perform a (weave) text merge for a given file and file-id.
56 If conflicts are encountered, .THIS and .OTHER files will be emitted,
57 and a conflict will be noted.
58 """
59- lines, conflicts = self._merged_lines(file_id)
60+ lines, base_lines = self._merged_lines(file_id)
61 lines = list(lines)
62 # Note we're checking whether the OUTPUT is binary in this case,
63 # because we don't want to get into weave merge guts.
64 textfile.check_text_lines(lines)
65 self.tt.create_file(lines, trans_id)
66- if conflicts:
67+ if base_lines is not None:
68+ # Conflict
69 self._raw_conflicts.append(('text conflict', trans_id))
70 name = self.tt.final_name(trans_id)
71 parent_id = self.tt.final_parent(trans_id)
72 file_group = self._dump_conflicts(name, parent_id, file_id,
73- no_base=True)
74+ no_base=False,
75+ base_lines=base_lines)
76 file_group.append(trans_id)
77
78
79 class LCAMerger(WeaveMerger):
80
81- def _merged_lines(self, file_id):
82- """Generate the merged lines.
83- There is no distinction between lines that are meant to contain <<<<<<<
84- and conflicts.
85- """
86- if self.cherrypick:
87- base = self.base_tree
88- else:
89- base = None
90- plan = self.this_tree.plan_file_lca_merge(file_id, self.other_tree,
91+ def _generate_merge_plan(self, file_id, base):
92+ return self.this_tree.plan_file_lca_merge(file_id, self.other_tree,
93 base=base)
94+<<<<<<< TREE
95 if 'merge' in debug.debug_flags:
96 plan = list(plan)
97 trans_id = self.tt.trans_id_file_id(file_id)
98@@ -1471,6 +1480,9 @@
99 '>>>>>>> MERGE-SOURCE\n')
100 return textmerge.merge_lines(self.reprocess)
101
102+=======
103+
104+>>>>>>> MERGE-SOURCE
105
106 class Diff3Merger(Merge3Merger):
107 """Three-way merger using external diff3 for text merging"""
108
109=== modified file 'bzrlib/tests/blackbox/test_merge.py'
110--- bzrlib/tests/blackbox/test_merge.py 2009-12-07 21:46:28 +0000
111+++ bzrlib/tests/blackbox/test_merge.py 2009-12-08 21:10:33 +0000
112@@ -211,6 +211,22 @@
113 self.failUnlessExists('sub/a.txt.OTHER')
114 self.failUnlessExists('sub/a.txt.BASE')
115
116+ def test_conflict_leaves_base_this_other_files(self):
117+ tree, other = self.create_conflicting_branches()
118+ self.run_bzr('merge ../other', working_dir='tree',
119+ retcode=1)
120+ self.assertFileEqual('a\nb\nc\n', 'tree/fname.BASE')
121+ self.assertFileEqual('a\nB\nD\n', 'tree/fname.OTHER')
122+ self.assertFileEqual('a\nB\nC\n', 'tree/fname.THIS')
123+
124+ def test_weave_conflict_leaves_base_this_other_files(self):
125+ tree, other = self.create_conflicting_branches()
126+ self.run_bzr('merge ../other --weave', working_dir='tree',
127+ retcode=1)
128+ self.assertFileEqual('a\nb\nc\n', 'tree/fname.BASE')
129+ self.assertFileEqual('a\nB\nD\n', 'tree/fname.OTHER')
130+ self.assertFileEqual('a\nB\nC\n', 'tree/fname.THIS')
131+
132 def test_merge_remember(self):
133 """Merge changes from one branch to another, test submit location."""
134 tree_a = self.make_branch_and_tree('branch_a')
135
136=== modified file 'bzrlib/tests/test_merge.py'
137--- bzrlib/tests/test_merge.py 2009-09-19 00:32:14 +0000
138+++ bzrlib/tests/test_merge.py 2009-12-08 21:10:33 +0000
139@@ -38,6 +38,7 @@
140 from bzrlib.workingtree import WorkingTree
141 from bzrlib.transform import TreeTransform
142
143+
144 class TestMerge(TestCaseWithTransport):
145 """Test appending more than one revision"""
146
147@@ -524,6 +525,12 @@
148 self.add_uncommitted_version(('root', 'C:'), [('root', 'A')], 'fabg')
149 return _PlanMerge('B:', 'C:', self.plan_merge_vf, ('root',))
150
151+ def test_base_from_plan(self):
152+ self.setup_plan_merge()
153+ plan = self.plan_merge_vf.plan_merge('B', 'C')
154+ pwm = versionedfile.PlanWeaveMerge(plan)
155+ self.assertEqual(['a\n', 'b\n', 'c\n'], pwm.base_from_plan())
156+
157 def test_unique_lines(self):
158 plan = self.setup_plan_merge()
159 self.assertEqual(plan._unique_lines(
160@@ -827,6 +834,111 @@
161 ('unchanged', 'f\n'),
162 ('unchanged', 'g\n')],
163 list(plan))
164+ plan = self.plan_merge_vf.plan_lca_merge('F', 'G')
165+ # This is one of the main differences between plan_merge and
166+ # plan_lca_merge. plan_lca_merge generates a conflict for 'x => z',
167+ # because 'x' was not present in one of the bases. However, in this
168+ # case it is spurious because 'x' does not exist in the global base A.
169+ self.assertEqual([
170+ ('unchanged', 'h\n'),
171+ ('unchanged', 'a\n'),
172+ ('conflicted-a', 'x\n'),
173+ ('new-b', 'z\n'),
174+ ('unchanged', 'c\n'),
175+ ('unchanged', 'd\n'),
176+ ('unchanged', 'y\n'),
177+ ('unchanged', 'f\n'),
178+ ('unchanged', 'g\n')],
179+ list(plan))
180+
181+ def test_criss_cross_flip_flop(self):
182+ # This is specificly trying to trigger problems when using limited
183+ # ancestry and weaves. The ancestry graph looks like:
184+ # XX unused ancestor, should not show up in the weave
185+ # |
186+ # A Unique LCA
187+ # / \
188+ # B C B & C both introduce a new line
189+ # |\ /|
190+ # | X |
191+ # |/ \|
192+ # D E B & C are both merged, so both are common ancestors
193+ # In the process of merging, both sides order the new
194+ # lines differently
195+ #
196+ self.add_rev('root', 'XX', [], 'qrs')
197+ self.add_rev('root', 'A', ['XX'], 'abcdef')
198+ self.add_rev('root', 'B', ['A'], 'abcdgef')
199+ self.add_rev('root', 'C', ['A'], 'abcdhef')
200+ self.add_rev('root', 'D', ['B', 'C'], 'abcdghef')
201+ self.add_rev('root', 'E', ['C', 'B'], 'abcdhgef')
202+ plan = list(self.plan_merge_vf.plan_merge('D', 'E'))
203+ self.assertEqual([
204+ ('unchanged', 'a\n'),
205+ ('unchanged', 'b\n'),
206+ ('unchanged', 'c\n'),
207+ ('unchanged', 'd\n'),
208+ ('new-b', 'h\n'),
209+ ('unchanged', 'g\n'),
210+ ('killed-b', 'h\n'),
211+ ('unchanged', 'e\n'),
212+ ('unchanged', 'f\n'),
213+ ], plan)
214+ pwm = versionedfile.PlanWeaveMerge(plan)
215+ self.assertEqualDiff('\n'.join('abcdghef') + '\n',
216+ ''.join(pwm.base_from_plan()))
217+ # Reversing the order reverses the merge plan, and final order of 'hg'
218+ # => 'gh'
219+ plan = list(self.plan_merge_vf.plan_merge('E', 'D'))
220+ self.assertEqual([
221+ ('unchanged', 'a\n'),
222+ ('unchanged', 'b\n'),
223+ ('unchanged', 'c\n'),
224+ ('unchanged', 'd\n'),
225+ ('new-b', 'g\n'),
226+ ('unchanged', 'h\n'),
227+ ('killed-b', 'g\n'),
228+ ('unchanged', 'e\n'),
229+ ('unchanged', 'f\n'),
230+ ], plan)
231+ pwm = versionedfile.PlanWeaveMerge(plan)
232+ self.assertEqualDiff('\n'.join('abcdhgef') + '\n',
233+ ''.join(pwm.base_from_plan()))
234+ # This is where lca differs, in that it (fairly correctly) determines
235+ # that there is a conflict because both sides resolved the merge
236+ # differently
237+ plan = list(self.plan_merge_vf.plan_lca_merge('D', 'E'))
238+ self.assertEqual([
239+ ('unchanged', 'a\n'),
240+ ('unchanged', 'b\n'),
241+ ('unchanged', 'c\n'),
242+ ('unchanged', 'd\n'),
243+ ('conflicted-b', 'h\n'),
244+ ('unchanged', 'g\n'),
245+ ('conflicted-a', 'h\n'),
246+ ('unchanged', 'e\n'),
247+ ('unchanged', 'f\n'),
248+ ], plan)
249+ pwm = versionedfile.PlanWeaveMerge(plan)
250+ self.assertEqualDiff('\n'.join('abcdgef') + '\n',
251+ ''.join(pwm.base_from_plan()))
252+ # Reversing it changes what line is doubled, but still gives a
253+ # double-conflict
254+ plan = list(self.plan_merge_vf.plan_lca_merge('E', 'D'))
255+ self.assertEqual([
256+ ('unchanged', 'a\n'),
257+ ('unchanged', 'b\n'),
258+ ('unchanged', 'c\n'),
259+ ('unchanged', 'd\n'),
260+ ('conflicted-b', 'g\n'),
261+ ('unchanged', 'h\n'),
262+ ('conflicted-a', 'g\n'),
263+ ('unchanged', 'e\n'),
264+ ('unchanged', 'f\n'),
265+ ], plan)
266+ pwm = versionedfile.PlanWeaveMerge(plan)
267+ self.assertEqualDiff('\n'.join('abcdhef') + '\n',
268+ ''.join(pwm.base_from_plan()))
269
270 def assertRemoveExternalReferences(self, filtered_parent_map,
271 child_map, tails, parent_map):
272
273=== modified file 'bzrlib/tests/test_merge_core.py'
274--- bzrlib/tests/test_merge_core.py 2009-08-20 04:09:58 +0000
275+++ bzrlib/tests/test_merge_core.py 2009-12-08 21:10:33 +0000
276@@ -434,6 +434,7 @@
277 self.assertEqual('text2', builder.this.get_file('1').read())
278 builder.cleanup()
279
280+
281 class FunctionalMergeTest(TestCaseWithTransport):
282
283 def test_trivial_star_merge(self):
284@@ -467,30 +468,61 @@
285 self.assertEqual("Mary\n", open("original/file2", "rt").read())
286
287 def test_conflicts(self):
288- os.mkdir('a')
289 wta = self.make_branch_and_tree('a')
290- a = wta.branch
291- file('a/file', 'wb').write('contents\n')
292+ self.build_tree_contents([('a/file', 'contents\n')])
293 wta.add('file')
294 wta.commit('base revision', allow_pointless=False)
295- d_b = a.bzrdir.clone('b')
296- b = d_b.open_branch()
297- file('a/file', 'wb').write('other contents\n')
298+ d_b = wta.branch.bzrdir.clone('b')
299+ self.build_tree_contents([('a/file', 'other contents\n')])
300 wta.commit('other revision', allow_pointless=False)
301- file('b/file', 'wb').write('this contents contents\n')
302+ self.build_tree_contents([('b/file', 'this contents contents\n')])
303 wtb = d_b.open_workingtree()
304 wtb.commit('this revision', allow_pointless=False)
305 self.assertEqual(1, wtb.merge_from_branch(wta.branch))
306- self.assert_(os.path.lexists('b/file.THIS'))
307- self.assert_(os.path.lexists('b/file.BASE'))
308- self.assert_(os.path.lexists('b/file.OTHER'))
309+ self.failUnlessExists('b/file.THIS')
310+ self.failUnlessExists('b/file.BASE')
311+ self.failUnlessExists('b/file.OTHER')
312 wtb.revert()
313 self.assertEqual(1, wtb.merge_from_branch(wta.branch,
314 merge_type=WeaveMerger))
315- self.assert_(os.path.lexists('b/file'))
316- self.assert_(os.path.lexists('b/file.THIS'))
317- self.assert_(not os.path.lexists('b/file.BASE'))
318- self.assert_(os.path.lexists('b/file.OTHER'))
319+ self.failUnlessExists('b/file')
320+ self.failUnlessExists('b/file.THIS')
321+ self.failUnlessExists('b/file.BASE')
322+ self.failUnlessExists('b/file.OTHER')
323+
324+ def test_weave_conflicts_not_in_base(self):
325+ builder = self.make_branch_builder('source')
326+ builder.start_series()
327+ # See bug #494197
328+ # A base revision (before criss-cross)
329+ # |\
330+ # B C B does nothing, C adds 'foo'
331+ # |X|
332+ # D E D and E modify foo in incompatible ways
333+ #
334+ # Merging will conflict, with C as a clean base text. However, the
335+ # current code uses A as the global base and 'foo' doesn't exist there.
336+ # It isn't trivial to create foo.BASE because it tries to look up
337+ # attributes like 'executable' in A.
338+ builder.build_snapshot('A-id', None, [
339+ ('add', ('', 'TREE_ROOT', 'directory', None))])
340+ builder.build_snapshot('B-id', ['A-id'], [])
341+ builder.build_snapshot('C-id', ['A-id'], [
342+ ('add', ('foo', 'foo-id', 'file', 'orig\ncontents\n'))])
343+ builder.build_snapshot('D-id', ['B-id', 'C-id'], [
344+ ('add', ('foo', 'foo-id', 'file', 'orig\ncontents\nand D\n'))])
345+ builder.build_snapshot('E-id', ['C-id', 'B-id'], [
346+ ('modify', ('foo-id', 'orig\ncontents\nand E\n'))])
347+ builder.finish_series()
348+ tree = builder.get_branch().create_checkout('tree', lightweight=True)
349+ self.assertEqual(1, tree.merge_from_branch(tree.branch,
350+ to_revision='D-id',
351+ merge_type=WeaveMerger))
352+ self.failUnlessExists('tree/foo.THIS')
353+ self.failUnlessExists('tree/foo.OTHER')
354+ self.expectFailure('fail to create .BASE in some criss-cross merges',
355+ self.failUnlessExists, 'tree/foo.BASE')
356+ self.failUnlessExists('tree/foo.BASE')
357
358 def test_merge_unrelated(self):
359 """Sucessfully merges unrelated branches with no common names"""
360
361=== modified file 'bzrlib/versionedfile.py'
362--- bzrlib/versionedfile.py 2009-10-19 15:06:58 +0000
363+++ bzrlib/versionedfile.py 2009-12-08 21:10:33 +0000
364@@ -1426,7 +1426,7 @@
365 def __init__(self, plan, a_marker=TextMerge.A_MARKER,
366 b_marker=TextMerge.B_MARKER):
367 TextMerge.__init__(self, a_marker, b_marker)
368- self.plan = plan
369+ self.plan = list(plan)
370
371 def _merge_struct(self):
372 lines_a = []
373@@ -1490,6 +1490,60 @@
374 for struct in outstanding_struct():
375 yield struct
376
377+ def base_from_plan(self):
378+ """Construct a BASE file from the plan text."""
379+ base_lines = []
380+ for state, line in self.plan:
381+ if state in ('killed-a', 'killed-b', 'killed-both', 'unchanged'):
382+ # If unchanged, then this line is straight from base. If a or b
383+ # or both killed the line, then it *used* to be in base.
384+ base_lines.append(line)
385+ else:
386+ if state not in ('killed-base', 'irrelevant',
387+ 'ghost-a', 'ghost-b',
388+ 'new-a', 'new-b',
389+ 'conflicted-a', 'conflicted-b'):
390+ # killed-base, irrelevant means it doesn't apply
391+ # ghost-a/ghost-b are harder to say for sure, but they
392+ # aren't in the 'inc_c' which means they aren't in the
393+ # shared base of a & b. So we don't include them. And
394+ # obviously if the line is newly inserted, it isn't in base
395+
396+ # If 'conflicted-a' or b, then it is new vs one base, but
397+ # old versus another base. However, if we make it present
398+ # in the base, it will be deleted from the target, and it
399+ # seems better to get a line doubled in the merge result,
400+ # rather than have it deleted entirely.
401+ # Example, each node is the 'text' at that point:
402+ # MN
403+ # / \
404+ # MaN MbN
405+ # | X |
406+ # MabN MbaN
407+ # \ /
408+ # ???
409+ # There was a criss-cross conflict merge. Both sides
410+ # include the other, but put themselves first.
411+ # Weave marks this as a 'clean' merge, picking OTHER over
412+ # THIS. (Though the details depend on order inserted into
413+ # weave, etc.)
414+ # LCA generates a plan:
415+ # [('unchanged', M),
416+ # ('conflicted-b', b),
417+ # ('unchanged', a),
418+ # ('conflicted-a', b),
419+ # ('unchanged', N)]
420+ # If you mark 'conflicted-*' as part of BASE, then a 3-way
421+ # merge tool will cleanly generate "MaN" (as BASE vs THIS
422+ # removes one 'b', and BASE vs OTHER removes the other)
423+ # If you include neither, 3-way creates a clean "MbabN" as
424+ # THIS adds one 'b', and OTHER does too.
425+ # It seems that having the line 2 times is better than
426+ # having it omitted. (Easier to manually delete than notice
427+ # it needs to be added.)
428+ raise AssertionError('Unknown state: %s' % (state,))
429+ return base_lines
430+
431
432 class WeaveMerge(PlanWeaveMerge):
433 """Weave merge that takes a VersionedFile and two versions as its input."""
434
435=== added file 'plan_to_base.py'
436--- plan_to_base.py 1970-01-01 00:00:00 +0000
437+++ plan_to_base.py 2009-12-08 21:10:33 +0000
438@@ -0,0 +1,76 @@
439+#!/usr/bin/env python
440+# Copyright (C) 2009 Canonical Ltd
441+#
442+# This program is free software; you can redistribute it and/or modify
443+# it under the terms of the GNU General Public License as published by
444+# the Free Software Foundation; either version 2 of the License, or
445+# (at your option) any later version.
446+#
447+# This program is distributed in the hope that it will be useful,
448+# but WITHOUT ANY WARRANTY; without even the implied warranty of
449+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
450+# GNU General Public License for more details.
451+#
452+# You should have received a copy of the GNU General Public License
453+# along with this program; if not, write to the Free Software
454+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
455+
456+"""A script that converts a bzr merge .plan file into a .BASE file."""
457+
458+import sys
459+
460+
461+def plan_lines_to_base_lines(plan_lines):
462+ _ghost_warning = False
463+ base_lines = []
464+ for line in plan_lines:
465+ action, content = line.split('|', 1)
466+ action = action.strip()
467+ if action in ('killed-a', 'killed-b', 'killed-both', 'unchanged'):
468+ # If lines were removed by a or b or both, then they must have been
469+ # in the base. if unchanged, then they are copied from the base
470+ base_lines.append(content)
471+ elif action in ('killed-base', 'irrelevant', 'ghost-a', 'ghost-b',
472+ 'new-a', 'new-b'):
473+ # The first 4 are ones that are always suppressed
474+ # the last 2 are lines that are in A or B, but *not* in BASE, so we
475+ # ignore them
476+ continue
477+ else:
478+ sys.stderr.write('Unknown action: %s\n' % (action,))
479+ return base_lines
480+
481+
482+def plan_file_to_base_file(plan_filename):
483+ if not plan_filename.endswith('.plan'):
484+ sys.stderr.write('"%s" does not look like a .plan file\n'
485+ % (plan_filename,))
486+ return
487+ plan_file = open(plan_filename, 'rb')
488+ try:
489+ plan_lines = plan_file.readlines()
490+ finally:
491+ plan_file.close()
492+ base_filename = plan_filename[:-4] + 'BASE'
493+ base_lines = plan_lines_to_base_lines(plan_lines)
494+ f = open(base_filename, 'wb')
495+ try:
496+ f.writelines(base_lines)
497+ finally:
498+ f.close()
499+
500+
501+def main(args):
502+ import optparse
503+ p = optparse.OptionParser('%prog foo.plan*')
504+
505+ opts, args = p.parse_args(args)
506+ if len(args) < 1:
507+ sys.stderr.write('You must supply exactly a .plan file.\n')
508+ return 1
509+ for arg in args:
510+ plan_file_to_base_file(arg)
511+
512+
513+if __name__ == '__main__':
514+ sys.exit(main(sys.argv[1:]))