git-p4: add unshelve command

This can be used to "unshelve" a shelved P4 commit into a git commit. For example: $ git p4 unshelve 12345 The resulting commit ends up in the branch: refs/remotes/p4/unshelved/12345 If that branch already exists, it is renamed - for example the above branch would be saved as p4/unshelved/12345.1. git-p4 checks that the shelved changelist is based on files which are at the same Perforce revision as the origin branch being used for the unshelve (HEAD by default). If they are not, it will refuse to unshelve. This is to ensure that the unshelved change does not contain other changes mixed-in. The reference branch can be changed manually with the "--origin" option. The change adds a new Unshelve command class. This just runs the existing P4Sync code tweaked to handle a shelved changelist. Signed-off-by: Luke Diamand <luke@diamand.org> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Luke Diamand committed May 23, 2018 at 23:20 UTC 123f631761dab8c37391ba1584122c2578f51923
3 files changed +347 -36
Documentation/git-p4.txt
+32
@@ -164,6 +164,31 @@ $ git p4 submit --shelve
164 $ git p4 submit --update-shelve 1234 --update-shelve 2345
165 ----
166
167 +
168 +Unshelve
169 +~~~~~~~~
170 +Unshelving will take a shelved P4 changelist, and produce the equivalent git commit
171 +in the branch refs/remotes/p4/unshelved/<changelist>.
172 +
173 +The git commit is created relative to the current origin revision (HEAD by default).
174 +If the shelved changelist's parent revisions differ, git-p4 will refuse to unshelve;
175 +you need to be unshelving onto an equivalent tree.
176 +
177 +The origin revision can be changed with the "--origin" option.
178 +
179 +If the target branch in refs/remotes/p4/unshelved already exists, the old one will
180 +be renamed.
181 +
182 +----
183 +$ git p4 sync
184 +$ git p4 unshelve 12345
185 +$ git show refs/remotes/p4/unshelved/12345
186 +<submit more changes via p4 to the same files>
187 +$ git p4 unshelve 12345
188 +<refuses to unshelve until git is in sync with p4 again>
189 +
190 +----
191 +
192 OPTIONS
193 -------
194
@@ -337,6 +362,13 @@ These options can be used to modify 'git p4 rebase' behavior.
362 --import-labels::
363 Import p4 labels.
364
365 +Unshelve options
366 +~~~~~~~~~~~~~~~~
367 +
368 +--origin::
369 + Sets the git refspec against which the shelved P4 changelist is compared.
370 + Defaults to p4/master.
371 +
372 DEPOT PATH SYNTAX
373 -----------------
374 The p4 depot path argument to 'git p4 sync' and 'git p4 clone' can
git-p4.py
+177 -36
@@ -316,12 +316,17 @@ def p4_last_change():
316 results = p4CmdList(["changes", "-m", "1"], skip_info=True)
317 return int(results[0]['change'])
318
319 -def p4_describe(change):
319 +def p4_describe(change, shelved=False):
320 """Make sure it returns a valid result by checking for
321 the presence of field "time". Return a dict of the
322 results."""
323
324 - ds = p4CmdList(["describe", "-s", str(change)], skip_info=True)
324 + cmd = ["describe", "-s"]
325 + if shelved:
326 + cmd += ["-S"]
327 + cmd += [str(change)]
328 +
329 + ds = p4CmdList(cmd, skip_info=True)
330 if len(ds) != 1:
331 die("p4 describe -s %d did not return 1 result: %s" % (change, str(ds)))
332
@@ -662,6 +667,12 @@ def gitBranchExists(branch):
667 stderr=subprocess.PIPE, stdout=subprocess.PIPE);
668 return proc.wait() == 0;
669
670 +def gitUpdateRef(ref, newvalue):
671 + subprocess.check_call(["git", "update-ref", ref, newvalue])
672 +
673 +def gitDeleteRef(ref):
674 + subprocess.check_call(["git", "update-ref", "-d", ref])
675 +
676 _gitConfig = {}
677
678 def gitConfig(key, typeSpecifier=None):
@@ -2411,6 +2422,7 @@ class P4Sync(Command, P4UserMap):
2422 self.tempBranches = []
2423 self.tempBranchLocation = "refs/git-p4-tmp"
2424 self.largeFileSystem = None
2425 + self.suppress_meta_comment = False
2426
2427 if gitConfig('git-p4.largeFileSystem'):
2428 largeFileSystemConstructor = globals()[gitConfig('git-p4.largeFileSystem')]
@@ -2421,6 +2433,18 @@ class P4Sync(Command, P4UserMap):
2433 if gitConfig("git-p4.syncFromOrigin") == "false":
2434 self.syncWithOrigin = False
2435
2436 + self.depotPaths = []
2437 + self.changeRange = ""
2438 + self.previousDepotPaths = []
2439 + self.hasOrigin = False
2440 +
2441 + # map from branch depot path to parent branch
2442 + self.knownBranches = {}
2443 + self.initialParents = {}
2444 +
2445 + self.tz = "%+03d%02d" % (- time.timezone / 3600, ((- time.timezone % 3600) / 60))
2446 + self.labels = {}
2447 +
2448 # Force a checkpoint in fast-import and wait for it to finish
2449 def checkpoint(self):
2450 self.gitStream.write("checkpoint\n\n")
@@ -2429,7 +2453,20 @@ class P4Sync(Command, P4UserMap):
2453 if self.verbose:
2454 print "checkpoint finished: " + out
2455
2432 - def extractFilesFromCommit(self, commit):
2456 + def cmp_shelved(self, path, filerev, revision):
2457 + """ Determine if a path at revision #filerev is the same as the file
2458 + at revision @revision for a shelved changelist. If they don't match,
2459 + unshelving won't be safe (we will get other changes mixed in).
2460 +
2461 + This is comparing the revision that the shelved changelist is *based* on, not
2462 + the shelved changelist itself.
2463 + """
2464 + ret = p4Cmd(["diff2", "{0}#{1}".format(path, filerev), "{0}@{1}".format(path, revision)])
2465 + if verbose:
2466 + print("p4 diff2 path %s filerev %s revision %s => %s" % (path, filerev, revision, ret))
2467 + return ret["status"] == "identical"
2468 +
2469 + def extractFilesFromCommit(self, commit, shelved=False, shelved_cl = 0, origin_revision = 0):
2470 self.cloneExclude = [re.sub(r"\.\.\.$", "", path)
2471 for path in self.cloneExclude]
2472 files = []
@@ -2452,6 +2489,19 @@ class P4Sync(Command, P4UserMap):
2489 file["rev"] = commit["rev%s" % fnum]
2490 file["action"] = commit["action%s" % fnum]
2491 file["type"] = commit["type%s" % fnum]
2492 + if shelved:
2493 + file["shelved_cl"] = int(shelved_cl)
2494 +
2495 + # For shelved changelists, check that the revision of each file that the
2496 + # shelve was based on matches the revision that we are using for the
2497 + # starting point for git-fast-import (self.initialParent). Otherwise
2498 + # the resulting diff will contain deltas from multiple commits.
2499 +
2500 + if file["action"] != "add" and \
2501 + not self.cmp_shelved(path, file["rev"], origin_revision):
2502 + sys.exit("change {0} not based on {1} for {2}, cannot unshelve".format(
2503 + commit["change"], self.initialParent, path))
2504 +
2505 files.append(file)
2506 fnum = fnum + 1
2507 return files
@@ -2743,7 +2793,16 @@ class P4Sync(Command, P4UserMap):
2793 def streamP4FilesCbSelf(entry):
2794 self.streamP4FilesCb(entry)
2795
2746 - fileArgs = ['%s#%s' % (f['path'], f['rev']) for f in filesToRead]
2796 + fileArgs = []
2797 + for f in filesToRead:
2798 + if 'shelved_cl' in f:
2799 + # Handle shelved CLs using the "p4 print file@=N" syntax to print
2800 + # the contents
2801 + fileArg = '%s@=%d' % (f['path'], f['shelved_cl'])
2802 + else:
2803 + fileArg = '%s#%s' % (f['path'], f['rev'])
2804 +
2805 + fileArgs.append(fileArg)
2806
2807 p4CmdList(["-x", "-", "print"],
2808 stdin=fileArgs,
@@ -2844,11 +2903,15 @@ class P4Sync(Command, P4UserMap):
2903 self.gitStream.write(details["desc"])
2904 if len(jobs) > 0:
2905 self.gitStream.write("\nJobs: %s" % (' '.join(jobs)))
2847 - self.gitStream.write("\n[git-p4: depot-paths = \"%s\": change = %s" %
2848 - (','.join(self.branchPrefixes), details["change"]))
2849 - if len(details['options']) > 0:
2850 - self.gitStream.write(": options = %s" % details['options'])
2851 - self.gitStream.write("]\nEOT\n\n")
2906 +
2907 + if not self.suppress_meta_comment:
2908 + self.gitStream.write("\n[git-p4: depot-paths = \"%s\": change = %s" %
2909 + (','.join(self.branchPrefixes), details["change"]))
2910 + if len(details['options']) > 0:
2911 + self.gitStream.write(": options = %s" % details['options'])
2912 + self.gitStream.write("]\n")
2913 +
2914 + self.gitStream.write("EOT\n\n")
2915
2916 if len(parent) > 0:
2917 if self.verbose:
@@ -3162,10 +3225,10 @@ class P4Sync(Command, P4UserMap):
3225 else:
3226 return None
3227
3165 - def importChanges(self, changes):
3228 + def importChanges(self, changes, shelved=False, origin_revision=0):
3229 cnt = 1
3230 for change in changes:
3168 - description = p4_describe(change)
3231 + description = p4_describe(change, shelved)
3232 self.updateOptionDict(description)
3233
3234 if not self.silent:
@@ -3235,7 +3298,7 @@ class P4Sync(Command, P4UserMap):
3298 print "Parent of %s not found. Committing into head of %s" % (branch, parent)
3299 self.commit(description, filesForCommit, branch, parent)
3300 else:
3238 - files = self.extractFilesFromCommit(description)
3301 + files = self.extractFilesFromCommit(description, shelved, change, origin_revision)
3302 self.commit(description, files, self.branch,
3303 self.initialParent)
3304 # only needed once, to connect to the previous commit
@@ -3300,17 +3363,23 @@ class P4Sync(Command, P4UserMap):
3363 print "IO error with git fast-import. Is your git version recent enough?"
3364 print self.gitError.read()
3365
3366 + def openStreams(self):
3367 + self.importProcess = subprocess.Popen(["git", "fast-import"],
3368 + stdin=subprocess.PIPE,
3369 + stdout=subprocess.PIPE,
3370 + stderr=subprocess.PIPE);
3371 + self.gitOutput = self.importProcess.stdout
3372 + self.gitStream = self.importProcess.stdin
3373 + self.gitError = self.importProcess.stderr
3374
3304 - def run(self, args):
3305 - self.depotPaths = []
3306 - self.changeRange = ""
3307 - self.previousDepotPaths = []
3308 - self.hasOrigin = False
3309 -
3310 - # map from branch depot path to parent branch
3311 - self.knownBranches = {}
3312 - self.initialParents = {}
3375 + def closeStreams(self):
3376 + self.gitStream.close()
3377 + if self.importProcess.wait() != 0:
3378 + die("fast-import failed: %s" % self.gitError.read())
3379 + self.gitOutput.close()
3380 + self.gitError.close()
3381
3382 + def run(self, args):
3383 if self.importIntoRemotes:
3384 self.refPrefix = "refs/remotes/p4/"
3385 else:
@@ -3497,15 +3566,7 @@ class P4Sync(Command, P4UserMap):
3566 b = b[len(self.projectName):]
3567 self.createdBranches.add(b)
3568
3500 - self.tz = "%+03d%02d" % (- time.timezone / 3600, ((- time.timezone % 3600) / 60))
3501 -
3502 - self.importProcess = subprocess.Popen(["git", "fast-import"],
3503 - stdin=subprocess.PIPE,
3504 - stdout=subprocess.PIPE,
3505 - stderr=subprocess.PIPE);
3506 - self.gitOutput = self.importProcess.stdout
3507 - self.gitStream = self.importProcess.stdin
3508 - self.gitError = self.importProcess.stderr
3569 + self.openStreams()
3570
3571 if revision:
3572 self.importHeadRevision(revision)
@@ -3585,11 +3646,7 @@ class P4Sync(Command, P4UserMap):
3646 missingP4Labels = p4Labels - gitTags
3647 self.importP4Labels(self.gitStream, missingP4Labels)
3648
3588 - self.gitStream.close()
3589 - if self.importProcess.wait() != 0:
3590 - die("fast-import failed: %s" % self.gitError.read())
3591 - self.gitOutput.close()
3592 - self.gitError.close()
3649 + self.closeStreams()
3650
3651 # Cleanup temporary branches created during import
3652 if self.tempBranches != []:
@@ -3721,6 +3778,89 @@ class P4Clone(P4Sync):
3778
3779 return True
3780
3781 +class P4Unshelve(Command):
3782 + def __init__(self):
3783 + Command.__init__(self)
3784 + self.options = []
3785 + self.origin = "HEAD"
3786 + self.description = "Unshelve a P4 changelist into a git commit"
3787 + self.usage = "usage: %prog [options] changelist"
3788 + self.options += [
3789 + optparse.make_option("--origin", dest="origin",
3790 + help="Use this base revision instead of the default (%s)" % self.origin),
3791 + ]
3792 + self.verbose = False
3793 + self.noCommit = False
3794 + self.destbranch = "refs/remotes/p4/unshelved"
3795 +
3796 + def renameBranch(self, branch_name):
3797 + """ Rename the existing branch to branch_name.N
3798 + """
3799 +
3800 + found = True
3801 + for i in range(0,1000):
3802 + backup_branch_name = "{0}.{1}".format(branch_name, i)
3803 + if not gitBranchExists(backup_branch_name):
3804 + gitUpdateRef(backup_branch_name, branch_name) # copy ref to backup
3805 + gitDeleteRef(branch_name)
3806 + found = True
3807 + print("renamed old unshelve branch to {0}".format(backup_branch_name))
3808 + break
3809 +
3810 + if not found:
3811 + sys.exit("gave up trying to rename existing branch {0}".format(sync.branch))
3812 +
3813 + def findLastP4Revision(self, starting_point):
3814 + """ Look back from starting_point for the first commit created by git-p4
3815 + to find the P4 commit we are based on, and the depot-paths.
3816 + """
3817 +
3818 + for parent in (range(65535)):
3819 + log = extractLogMessageFromGitCommit("{0}^{1}".format(starting_point, parent))
3820 + settings = extractSettingsGitLog(log)
3821 + if settings.has_key('change'):
3822 + return settings
3823 +
3824 + sys.exit("could not find git-p4 commits in {0}".format(self.origin))
3825 +
3826 + def run(self, args):
3827 + if len(args) != 1:
3828 + return False
3829 +
3830 + if not gitBranchExists(self.origin):
3831 + sys.exit("origin branch {0} does not exist".format(self.origin))
3832 +
3833 + sync = P4Sync()
3834 + changes = args
3835 + sync.initialParent = self.origin
3836 +
3837 + # use the first change in the list to construct the branch to unshelve into
3838 + change = changes[0]
3839 +
3840 + # if the target branch already exists, rename it
3841 + branch_name = "{0}/{1}".format(self.destbranch, change)
3842 + if gitBranchExists(branch_name):
3843 + self.renameBranch(branch_name)
3844 + sync.branch = branch_name
3845 +
3846 + sync.verbose = self.verbose
3847 + sync.suppress_meta_comment = True
3848 +
3849 + settings = self.findLastP4Revision(self.origin)
3850 + origin_revision = settings['change']
3851 + sync.depotPaths = settings['depot-paths']
3852 + sync.branchPrefixes = sync.depotPaths
3853 +
3854 + sync.openStreams()
3855 + sync.loadUserMapFromCache()
3856 + sync.silent = True
3857 + sync.importChanges(changes, shelved=True, origin_revision=origin_revision)
3858 + sync.closeStreams()
3859 +
3860 + print("unshelved changelist {0} into {1}".format(change, branch_name))
3861 +
3862 + return True
3863 +
3864 class P4Branches(Command):
3865 def __init__(self):
3866 Command.__init__(self)
@@ -3775,7 +3915,8 @@ commands = {
3915 "rebase" : P4Rebase,
3916 "clone" : P4Clone,
3917 "rollback" : P4RollBack,
3778 - "branches" : P4Branches
3918 + "branches" : P4Branches,
3919 + "unshelve" : P4Unshelve,
3920 }
3921
3922
t/t9832-unshelve.sh new
+138
@@ -0,0 +1,138 @@
1 +#!/bin/sh
2 +
3 +last_shelved_change () {
4 + p4 changes -s shelved -m1 | cut -d " " -f 2
5 +}
6 +
7 +test_description='git p4 unshelve'
8 +
9 +. ./lib-git-p4.sh
10 +
11 +test_expect_success 'start p4d' '
12 + start_p4d
13 +'
14 +
15 +test_expect_success 'init depot' '
16 + (
17 + cd "$cli" &&
18 + echo file1 >file1 &&
19 + p4 add file1 &&
20 + p4 submit -d "change 1" &&
21 + : >file_to_delete &&
22 + p4 add file_to_delete &&
23 + p4 submit -d "file to delete"
24 + )
25 +'
26 +
27 +test_expect_success 'initial clone' '
28 + git p4 clone --dest="$git" //depot/@all
29 +'
30 +
31 +test_expect_success 'create shelved changelist' '
32 + (
33 + cd "$cli" &&
34 + p4 edit file1 &&
35 + echo "a change" >>file1 &&
36 + echo "new file" >file2 &&
37 + p4 add file2 &&
38 + p4 delete file_to_delete &&
39 + p4 opened &&
40 + p4 shelve -i <<EOF
41 +Change: new
42 +Description:
43 + Test commit
44 +
45 + Further description
46 +Files:
47 + //depot/file1
48 + //depot/file2
49 + //depot/file_to_delete
50 +EOF
51 +
52 + ) &&
53 + (
54 + cd "$git" &&
55 + change=$(last_shelved_change) &&
56 + git p4 unshelve $change &&
57 + git show refs/remotes/p4/unshelved/$change | grep -q "Further description" &&
58 + git cherry-pick refs/remotes/p4/unshelved/$change &&
59 + test_path_is_file file2 &&
60 + test_cmp file1 "$cli"/file1 &&
61 + test_cmp file2 "$cli"/file2 &&
62 + test_path_is_missing file_to_delete
63 + )
64 +'
65 +
66 +test_expect_success 'update shelved changelist and re-unshelve' '
67 + test_when_finished cleanup_git &&
68 + (
69 + cd "$cli" &&
70 + change=$(last_shelved_change) &&
71 + echo "file3" >file3 &&
72 + p4 add -c $change file3 &&
73 + p4 shelve -i -r <<EOF &&
74 +Change: $change
75 +Description:
76 + Test commit
77 +
78 + Further description
79 +Files:
80 + //depot/file1
81 + //depot/file2
82 + //depot/file3
83 + //depot/file_to_delete
84 +EOF
85 + p4 describe $change
86 + ) &&
87 + (
88 + cd "$git" &&
89 + change=$(last_shelved_change) &&
90 + git p4 unshelve $change &&
91 + git diff refs/remotes/p4/unshelved/$change.0 refs/remotes/p4/unshelved/$change | grep -q file3
92 + )
93 +'
94 +
95 +# This is the tricky case where the shelved changelist base revision doesn't
96 +# match git-p4's idea of the base revision
97 +#
98 +# We will attempt to unshelve a change that is based on a change one commit
99 +# ahead of p4/master
100 +
101 +test_expect_success 'create shelved changelist based on p4 change ahead of p4/master' '
102 + git p4 clone --dest="$git" //depot/@all &&
103 + (
104 + cd "$cli" &&
105 + p4 revert ... &&
106 + p4 edit file1 &&
107 + echo "foo" >>file1 &&
108 + p4 submit -d "change:foo" &&
109 + p4 edit file1 &&
110 + echo "bar" >>file1 &&
111 + p4 shelve -i <<EOF &&
112 +Change: new
113 +Description:
114 + Change to be unshelved
115 +Files:
116 + //depot/file1
117 +EOF
118 + change=$(last_shelved_change) &&
119 + p4 describe -S $change | grep -q "Change to be unshelved"
120 + )
121 +'
122 +
123 +# Now try to unshelve it. git-p4 should refuse to do so.
124 +test_expect_success 'try to unshelve the change' '
125 + test_when_finished cleanup_git &&
126 + (
127 + change=$(last_shelved_change) &&
128 + cd "$git" &&
129 + test_must_fail git p4 unshelve $change 2>out.txt &&
130 + grep -q "cannot unshelve" out.txt
131 + )
132 +'
133 +
134 +test_expect_success 'kill p4d' '
135 + kill_p4d
136 +'
137 +
138 +test_done