Add some API to use Record as a standalone class
[vng.git] / src / commands / Record.cpp
blobcc6efe383f81cd64adcfab4387345e0b5d6d9668
1 /*
2 * This file is part of the vng project
3 * Copyright (C) 2008 Thomas Zander <tzander@trolltech.com>
4 * Copyright (C) 2002-2004 David Roundy
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <http://www.gnu.org/licenses/>.
19 #include "Record.h"
20 #include "CommandLineParser.h"
21 #include "GitRunner.h"
22 #include "hunks/ChangeSet.h"
23 #include "hunks/HunksCursor.h"
24 #include "Logger.h"
25 #include "Interview.h"
26 #include "Vng.h"
28 #include <QDebug>
29 #include <QBuffer>
31 #ifdef Q_OS_WIN
32 # include <cstdlib>
33 # define getpid() rand()
34 #else
35 # include <sys/types.h>
36 # include <unistd.h>
37 #endif
40 static const CommandLineOption options[] = {
41 {"-a, --all", "answer yes to all patches"},
42 {"-i, --interactive", "prompt user interactively"},
43 {"-m, --patch-name PATCHNAME", "name of patch"},
44 {"-A, --author EMAIL", "specify author id"},
45 //{"--logfile FILE", "give patch name and comment in file"},
46 //{"--delete-logfile", "delete the logfile when done"},
47 //{"--no-test", "don't run the test script"},
48 //{"--test", "run the test script"},
49 //{"--leave-test-directory", "don't remove the test directory"},
50 //{"--remove-test-directory", "remove the test directory"},
51 //{"--edit-long-comment", "Edit the long comment by default"},
52 //{"--skip-long-comment", "Don't give a long comment"},
53 //{"--prompt-long-comment", "Prompt for whether to edit the long comment"},
54 //{"-l, --look-for-adds", "Also look for files that are potentially pending addition"},
55 //{"--dont-look-for-adds", "Don't look for any files that could be added"},
56 //{"--umask UMASK", "specify umask to use when writing."},
57 //{"--posthook COMMAND", "specify command to run after this vng command."},
58 //{"--no-posthook", "Do not run posthook command."},
59 //{"--prompt-posthook", "Prompt before running posthook. [DEFAULT]"},
60 //{"--run-posthook", "Run posthook command without prompting."},
61 CommandLineLastOption
64 Record::Record()
65 : AbstractCommand("record"),
66 m_mode(Unset)
68 CommandLineParser::addOptionDefinitions(options);
69 CommandLineParser::setArgumentDefinition("record [FILE or DIRECTORY]" );
72 QString Record::argumentDescription() const
74 return "[FILE or DIRECTORY]";
77 QString Record::commandDescription() const
79 return "Record is used to name a set of changes and record the patch to the repository.\n";
82 void Record::setPatchName(const QByteArray &message)
84 m_patchName = message;
87 QByteArray Record::patchName() const
89 return m_patchName;
92 void Record::setUsageMode(UsageMode mode)
94 m_mode = mode;
97 QString Record::sha1() const
99 return m_sha1;
102 AbstractCommand::ReturnCodes Record::run()
104 if (! checkInRepository())
105 return NotInRepo;
106 moveToRoot(CheckFileSystem);
108 CommandLineParser *args = CommandLineParser::instance();
109 bool all;
110 if (m_mode == Unset) {
111 all = m_config.isEmptyRepo()
112 || (m_config.contains("all") && !args->contains("interactive"))
113 || args->contains("all");
115 else
116 all = m_mode == AllChanges;
117 const bool needHunks = !all || !args->contains("patch-name");
119 ChangeSet changeSet;
120 changeSet.fillFromCurrentChanges(rebasedArguments(), needHunks);
122 changeSet.waitForFinishFirstFile();
123 bool shouldDoRecord = changeSet.count() > 0;
124 if (!shouldDoRecord) {
125 Logger::warn() << "No changes!" << endl;
126 return Ok;
129 QString email = args->optionArgument("author", m_config.optionArgument("author", getenv("EMAIL")));
130 QStringList environment;
131 if (! email.isEmpty()) {
132 QRegExp re("(.*) <([@\\S]+)>");
133 if (re.exactMatch(email)) { // meaning its an email AND name
134 environment << "GIT_AUTHOR_NAME="+ re.cap(1);
135 environment << "GIT_COMMITTER_NAME="+ re.cap(1);
136 environment << "GIT_AUTHOR_EMAIL="+ re.cap(2);
137 environment << "GIT_COMMITTER_EMAIL="+ re.cap(2);
139 else if (!args->contains("author")) { // if its an account or shell wide option; just use the git defs.
140 environment << "GIT_AUTHOR_EMAIL="+ email;
141 environment << "GIT_COMMITTER_EMAIL="+ email;
143 else {
144 Logger::error() << "Author format invalid. Please provide author formatted like; `name <email@host>\n";
145 return InvalidOptions;
149 if (shouldDoRecord && !all && m_mode != Index) { // then do interview
150 HunksCursor cursor(changeSet);
151 cursor.setConfiguration(m_config);
152 Interview interview(cursor, name());
153 interview.setUsePager(shouldUsePager());
154 if (! interview.start()) {
155 Logger::warn() << "Record cancelled." << endl;
156 return Ok;
160 if (shouldDoRecord && !all && m_mode != Index) { // check if there is anything selected
161 shouldDoRecord = changeSet.hasAcceptedChanges();
162 if (! shouldDoRecord) {
163 Logger::warn() << "Ok, if you don't want to record anything, that's fine!" <<endl;
164 return Ok;
167 if (m_patchName.isEmpty())
168 m_patchName = args->optionArgument("patch-name", m_config.optionArgument("patch-name")).toUtf8();
169 if (dryRun())
170 return Ok;
172 if (m_patchName.isEmpty() && getenv("EDITOR")) {
173 class Deleter : public QObject {
174 public:
175 Deleter() : commitMessage(0) { }
176 ~Deleter() {
177 if (commitMessage)
178 commitMessage->remove();
180 QFile *commitMessage;
182 Deleter parent;
183 QFile *commitMessage;
184 int i = 0;
185 do {
186 commitMessage = new QFile(QString("vng-record-%1").arg(i++), &parent);
187 } while (commitMessage->exists());
188 parent.commitMessage = commitMessage; // make sure the file is removed from FS.
189 if (! commitMessage->open(QIODevice::WriteOnly)) {
190 Logger::error() << "Vng failed. Could not create a temporary file for the record message '"
191 << commitMessage->fileName() << "`\n";
192 return WriteError;
194 const char * defaultCommitMessage1 = "\n***END OF DESCRIPTION***"; // we will look for this string later
195 const char * defaultCommitMessage2 = "\nPlace the long patch description above the ***END OF DESCRIPTION*** marker.\n\nThis patch contains the following changes:\n\n";
196 commitMessage->write("\n", 1);
197 commitMessage->write(defaultCommitMessage1, strlen(defaultCommitMessage1));
198 commitMessage->write(defaultCommitMessage2, strlen(defaultCommitMessage2));
199 QBuffer buffer;
200 changeSet.writeDiff(buffer, all ? ChangeSet::AllHunks : ChangeSet::UserSelection);
201 ChangeSet actualChanges;
202 actualChanges.fillFromDiffFile(buffer);
203 QTextStream out (commitMessage);
204 for (int i=0; i < actualChanges.count(); ++i) {
205 File file = actualChanges.file(i);
206 file.outputWhatsChanged(out, m_config, true, false);
208 for (int i=0; i < changeSet.count(); ++i) {
209 File file = changeSet.file(i);
210 if (file.isBinary() && (all || file.binaryChangeAcceptance() == Vng::Accepted))
211 out << "M " << QString::fromLocal8Bit(file.fileName());
212 else if (file.fileName().isEmpty() && (all || file.renameAcceptance() == Vng::Accepted))
213 out << "D " << QString::fromLocal8Bit(file.oldFileName());
215 out.flush();
217 commitMessage->close();
218 QDateTime modification = QFileInfo(*commitMessage).lastModified();
219 QString command = QString("%1 %2").arg(getenv("EDITOR")).arg(commitMessage->fileName());
220 int rc = system(command.toAscii().data());
221 if (rc != 0) {
222 // this will keep patchName empty and we fall through to the interview.
223 Logger::warn() << "Vng-Warning: Could not start editor '" << getenv("EDITOR") << "`\n";
224 Logger::warn().flush();
226 else if (modification == QFileInfo(*commitMessage).lastModified()) {
227 Logger::warn() << "unchanged, won't record\n";
228 return Ok;
230 else {
231 // get data until the separator line.
232 commitMessage->open(QIODevice::ReadOnly);
233 m_patchName = commitMessage->readAll();
234 commitMessage->close();
235 int cuttoff = m_patchName.indexOf(defaultCommitMessage1);
236 if (cuttoff > 0)
237 m_patchName.truncate(cuttoff);
238 for (int i = m_patchName.length()-1; i >= 0; --i) {
239 if (m_patchName[i] == '\n' || m_patchName[i] == '\r' || m_patchName[i] == ' ')
240 m_patchName.resize(i); // shrink
241 else break;
245 if (m_patchName.isEmpty())
246 m_patchName = Interview::ask("What is the patch name? ").toUtf8();
248 ReturnCodes rc = addFilesPerAcceptance(changeSet, all);
249 if (rc != Ok)
250 return rc;
252 QProcess git;
253 QStringList arguments;
254 arguments << "write-tree";
255 GitRunner runner(git, arguments);
256 rc = runner.start(GitRunner::WaitForStandardOutput);
257 if (rc != Ok) {
258 Logger::error() << "Git write-tree failed!, aborting record\n";
259 return rc;
261 char buf[1024];
262 Vng::readLine(&git, buf, sizeof(buf));
263 QString tree(buf);
264 git.waitForFinished(); // patiently wait for it to finish..
265 Logger::debug() << "The tree got git ref; " << tree;
266 Logger::debug().flush(); // flush since we do an ask next
268 arguments.clear();
269 git.setEnvironment(environment);
271 arguments << "commit-tree" << tree.left(40);
272 if (!m_config.isEmptyRepo())
273 arguments << "-p" << "HEAD" ;
275 runner.setArguments(arguments);
276 rc = runner.start(GitRunner::WaitUntilReadyForWrite);
277 if (rc != Ok) {
278 Logger::error() << "Git commit-tree failed!, aborting record\n";
279 return rc;
281 git.write(m_patchName);
282 git.write("\n");
283 git.closeWriteChannel();
284 Vng::readLine(&git, buf, sizeof(buf));
285 QString commit(buf);
286 Logger::debug() << "commit is ref; " << commit;
287 git.waitForFinished(); // patiently wait for it to finish..
288 if (commit.isEmpty()) {
289 Logger::error() << "Git update-ref failed to produce a reference!, aborting record\n";
290 return GitFailed;
293 arguments.clear();
294 arguments << "update-ref" << "HEAD" << commit.left(40);
295 runner.setArguments(arguments);
296 rc = runner.start(GitRunner::WaitUntilFinished);
297 if (rc != Ok) {
298 Logger::error() << "Git update-ref failed!, aborting record\n";
299 return rc;
302 // We removed files from the index in case they were freshly added, but the user didn't want it in this commit.
303 // we have to re-add those files.
304 arguments.clear();
305 arguments << "update-index" << "--add";
306 for (int i=0; i < changeSet.count(); ++i) {
307 File file = changeSet.file(i);
308 if (! file.oldFileName().isEmpty())
309 continue; // not a new added file.
310 if (file.renameAcceptance() == Vng::Rejected)
311 arguments.append(file.fileName());
313 if (arguments.count() > 2) {
314 runner.setArguments(arguments);
315 runner.start(GitRunner::WaitUntilFinished);
318 int endOfLine = m_patchName.indexOf('\n');
319 if (endOfLine > 0)
320 m_patchName.truncate(endOfLine);
321 Logger::warn() << "Finished recording patch `" << m_patchName << "'" << endl;
322 return Ok;
325 AbstractCommand::ReturnCodes Record::addFilesPerAcceptance(const ChangeSet &changeSet, bool allChanges)
327 typedef QPair<QString, QString> NamePair;
328 class RevertCopier {
329 public:
330 RevertCopier() {}
331 ~RevertCopier() {
332 foreach(NamePair pair, m_fileNames) {
333 QFile copy(pair.second);
334 copy.remove();
335 if (! pair.first.isEmpty()) {
336 QFile orig(pair.first);
337 orig.rename(copy.fileName());
341 void append(const QString &orig, const QString &copy) {
342 QPair<QString, QString> pair(orig, copy);
343 m_fileNames.append(pair);
345 private:
346 // thats orig, copy
347 QList< QPair<QString, QString> > m_fileNames;
349 RevertCopier reverter;// this will revert all file changes we make when we exit the scope of this method.
350 ChangeSet patchChanges;
352 QStringList filesForAdd;
353 QStringList notUsedFiles;
354 for (int i=0; i < changeSet.count(); ++i) {
355 File file = changeSet.file(i);
356 bool someEnabled = false;
357 bool allEnabled = true;
358 const bool renamed = file.fileName() != file.oldFileName();
359 const bool protectionChanged = !renamed && file.protection() != file.oldProtection();
360 if ((file.renameAcceptance() == Vng::Accepted && renamed)
361 || (file.protectionAcceptance() == Vng::Accepted && protectionChanged)) // record it.
362 someEnabled = true;
364 foreach (Hunk hunk, file.hunks()) {
365 Vng::Acceptance a = hunk.acceptance();
366 if (a == Vng::Accepted) {
367 someEnabled = true;
368 } else if (a == Vng::MixedAcceptance) {
369 someEnabled = true;
370 allEnabled = false;
371 } else {
372 allEnabled = false;
374 if (someEnabled && !allEnabled)
375 break;
377 const bool addUnaltered = allChanges || allEnabled;
379 const bool removeFromIndex = !allChanges && !someEnabled; // user said 'no', lets make sure none of the changes are left in the index.
380 if (removeFromIndex && ! file.fileName().isEmpty() && ! file.oldFileName().isEmpty()) { // just ignore file.
381 notUsedFiles << file.fileName();
382 continue;
384 Logger::debug() << "'" << file.fileName() << "` addUnaltered: " << addUnaltered << ", removeFromIndex: "
385 << removeFromIndex << ", someChangesAccepted: " << someEnabled << ", allAccepted: " << allEnabled << endl;
386 if (file.fileName().isEmpty())
387 filesForAdd << file.oldFileName();
388 else {
389 filesForAdd << file.fileName();
390 if (renamed)
391 filesForAdd << file.oldFileName();
394 if (addUnaltered)
395 continue; // thats easy; whole file to add.
396 if (removeFromIndex && file.fileName().isEmpty()) { // user said no about recording a deleted file.
397 // this is a funny situation; *just* in case the user already somehow added the deletion to the index
398 // we need to reset that to make the index have the full file again. Notice that we need to remove the file afterwards.
399 Q_ASSERT(!file.oldFileName().isEmpty());
400 QProcess git;
401 QStringList arguments;
402 arguments << "cat-file" << "blob" << file.oldSha1();
403 GitRunner runner(git, arguments);
404 ReturnCodes rc = runner.start(GitRunner::WaitForStandardOutput);
405 Logger::debug() << "restoring '" << file.oldFileName() << "`\n";
406 if (rc == Ok) {
407 reverter.append(QString(), file.oldFileName());
408 QFile deletedFile(file.oldFileName());
409 Q_ASSERT(! deletedFile.exists());
410 bool success = Vng::copyFile(git, deletedFile);
411 git.waitForFinished();
412 deletedFile.close();
413 if (! success)
414 return WriteError;
416 continue;
419 // for the case where only some patches are selected we make a safety copy of the file.
420 QFile sourceFile(file.fileName());
421 Q_ASSERT(sourceFile.exists());
422 QString fileName = file.fileName();
423 for(int i=0; i < 10; i++) {
424 fileName = fileName + ".orig";
425 if (sourceFile.rename(fileName))
426 break; // successful!
428 reverter.append(fileName, file.fileName());
429 if (removeFromIndex) // need to rename it only, we won't patch it.
430 continue;
432 patchChanges.addFile(file);
433 Logger::debug() << "cp " << fileName << " =>" << file.fileName() << endl;
434 QFile orig(fileName);
435 orig.open(QIODevice::ReadOnly);
436 Vng::copyFile(orig, sourceFile); // TODO check return code
437 sourceFile.setPermissions(file.permissions());
440 QFile patch(".vng.record." + QString::number(getpid()) + ".diff");
441 patchChanges.writeDiff(patch, ChangeSet::InvertedUserSelection);
442 patch.close();
444 if (patch.size() != 0) {
445 QProcess git;
446 QStringList arguments;
447 arguments << "apply" << "--apply" << "--reverse" << patch.fileName();
448 GitRunner runner(git, arguments);
449 ReturnCodes rc = runner.start(GitRunner::WaitUntilFinished);
450 if (rc != Ok) {
451 Logger::error() << "Vng failed: failed to patch, sorry! Aborting record.\n";
452 return rc; // note that copied files will be moved back to avoid partially patched files lying around.
455 patch.remove();
456 // first clean out the index so that previous stuff other tools did doesn't influence us.
457 if (!m_config.isEmptyRepo() && ! notUsedFiles.isEmpty()) {
458 QProcess git;
459 QStringList arguments;
460 arguments << "reset" << "-q" << "HEAD"; // -q stands for quiet.
461 arguments += notUsedFiles;
462 GitRunner runner(git, arguments);
463 ReturnCodes rc = runner.start(GitRunner::WaitUntilFinished);
464 // if (rc != Ok) { // why o why does 'git reset' return 1 when it succeeded just fine.
465 // Logger::error() << "Cleaning the index failed, aborting record\n";
466 // return rc;
467 // }
470 // git add of all files to get a nice list of changes into the index.
471 while (!filesForAdd.isEmpty()) {
472 QProcess git;
473 QStringList arguments;
474 arguments << "update-index" << "--add" << "--remove";
475 int count = 25; // length of arguments
476 do {
477 QString first = filesForAdd[0];
478 if (count + first.length() > 32000)
479 break;
480 count += first.length();
481 arguments.append(first);
482 filesForAdd.removeFirst();
483 } while (!filesForAdd.isEmpty());
484 GitRunner runner(git, arguments);
485 ReturnCodes rc = runner.start(GitRunner::WaitForStandardOutput);
486 if (rc != Ok) {
487 Logger::error() << "Vng failed: Did not manage to add files to the git index, aborting record\n";
488 return rc;
492 return Ok; // exiting scope will revert all files in the local filesystem. We use the index from here on.