diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a534e8..022a391 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,10 @@ on: pull_request: branches: [ '*' ] +concurrency: + group: ${{ github.head_ref }} + cancel-in-progress: true + jobs: integration_tests: name: Integration tests @@ -31,5 +35,11 @@ jobs: uses: actions/checkout@v3 - name: Test run: | - xcodebuild -scheme system7-tests -configuration Release test | xcpretty - exit ${PIPESTATUS[0]} + xcodebuild -scheme system7-tests -configuration Release -resultBundlePath UnitTests.xcresult test + exit $? + - name: Upload xcresult file + uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: UnitTests-${{ github.run_number }}.xcresult + path: UnitTests.xcresult diff --git a/.gitignore b/.gitignore index 5a26f67..1f8ff89 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ usr *.xcworkspacedata system7-tests/integration/case7/*.actual system7-tests/integration/sandbox +system7-tests/integration/templates .last-s7-update-check-date diff --git a/system7-tests/bootstrapTests.m b/system7-tests/bootstrapTests.m index 034ac7b..736b891 100644 --- a/system7-tests/bootstrapTests.m +++ b/system7-tests/bootstrapTests.m @@ -60,7 +60,7 @@ - (void)testOnLFSRepo_WITH_NO_LFSHooksInstalled { [self runBootstrap]; - XCTAssertFalse([self doesPostCheckoutHookContainInitCall]); + XCTAssertTrue([self doesPostCheckoutHookContainInitCall]); return S7ExitCodeSuccess; }); diff --git a/system7-tests/deinitTests.m b/system7-tests/deinitTests.m index cb5e13c..045aa49 100644 --- a/system7-tests/deinitTests.m +++ b/system7-tests/deinitTests.m @@ -44,7 +44,9 @@ void assertRepoAtPWDIsFreeFromS7(void) { if ([fileManager fileExistsAtPath:@".git/config"]) { NSString *configContents = [NSString stringWithContentsOfFile:@".git/config" encoding:NSUTF8StringEncoding error:nil]; - XCTAssertFalse([configContents containsString:@"s7"]); + XCTAssertFalse([configContents containsString:@"s7 merge-driver"], + @"Git Config should not contain 's7' mentions. Actual config: %@", + configContents); } NSArray *hookFileNames = @[ diff --git a/system7-tests/integration/case-cloneS7andLFSmixedRepo.sh b/system7-tests/integration/case-cloneS7andLFSmixedRepo.sh new file mode 100644 index 0000000..7b7f32a --- /dev/null +++ b/system7-tests/integration/case-cloneS7andLFSmixedRepo.sh @@ -0,0 +1,73 @@ +#!/bin/sh + +git clone github/rd2 pastey/rd2 + +cd pastey/rd2 + +LARGE_FILE_CONTENT="MEGA-LONG-FILE-CONTENT" +echo "$LARGE_FILE_CONTENT" > large-file +assert git lfs track large-file + +assert git add large-file .gitattributes + +git commit -m"\"track large file with Git LFS\"" + +assert s7 init +assert git add . +assert git commit -m "\"init s7\"" + +assert s7 add --stage Dependencies/RDPDFKit '"$S7_ROOT/github/RDPDFKit"' +git commit -m"add pdfkit subrepo" + +grep "s7" .git/hooks/pre-push +assert test 0 -eq $? + +grep "lfs" .git/hooks/pre-push +assert test 0 -eq $? + +assert test "2" = "$(grep -c '<"$REFS"' .git/hooks/pre-push)" + +git push + +cd "$S7_ROOT/nik" + +git clone "$S7_ROOT/github/rd2" +assert test $? -eq 0 + +cd rd2 + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? + +grep -q "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +assert test "$(cat large-file)" = "$LARGE_FILE_CONTENT" +assert test -f Dependencies/RDPDFKit/.gitignore + + +mkdir etalon-lfs-repo +pushd etalon-lfs-repo > /dev/null + git init + git lfs install +popd > /dev/null + +for ETALON_HOOK in etalon-lfs-repo/.git/hooks/*; do + if grep -i "lfs" $ETALON_HOOK; then + HOOK_NAME="$(basename $ETALON_HOOK)" + NIKS_HOOK=".git/hooks/$HOOK_NAME" + if [ "$(sed -n 's/ <&0//; s/ <"$REFS"//; /lfs/p;1p' $NIKS_HOOK)" != "$(cat $ETALON_HOOK)" ]; then + echo "LFS hooks hardcoded in S7 code are outdated!" + echo "Expected format:" + echo + cat $ETALON_HOOK + echo + echo "Actual format:" + echo + sed -n '/lfs/p' $NIKS_HOOK + echo + + assert false + fi + fi +done diff --git a/system7-tests/integration/case-pullLFSintoExistingS7Repo.sh b/system7-tests/integration/case-pullLFSintoExistingS7Repo.sh new file mode 100644 index 0000000..b694470 --- /dev/null +++ b/system7-tests/integration/case-pullLFSintoExistingS7Repo.sh @@ -0,0 +1,82 @@ +#!/bin/sh + +git clone github/rd2 pastey/rd2 + +cd pastey/rd2 + +assert s7 init +assert git add . +assert git commit -m "\"init s7\"" + +assert s7 add --stage Dependencies/RDPDFKit '"$S7_ROOT/github/RDPDFKit"' +git commit -m"add pdfkit subrepo" + +git push + +cd "$S7_ROOT/nik" + +assert git clone "$S7_ROOT/github/rd2" + +cd rd2 + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +assert test -f Dependencies/RDPDFKit/.gitignore + + +cd "$S7_ROOT/pastey/rd2" + +LARGE_FILE_CONTENT="MEGA-LONG-FILE-CONTENT" +echo "$LARGE_FILE_CONTENT" > large-file +assert git lfs track large-file + +# re-initialize hooks for both: s7 and LFS +assert s7 init + +assert git add large-file +git add . +git commit -m"\"track large file with Git LFS\"" + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +grep -i "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +grep "s7" .git/hooks/pre-push +assert test 0 -eq $? +grep -i "lfs" .git/hooks/pre-push +assert test 0 -eq $? + +git push + + +cd "$S7_ROOT/nik/rd2" + +PRE_LFS_COMMIT="$(git rev-parse HEAD)" + +assert git pull + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +grep -i "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +assert test -f large-file +assert test "$(cat large-file)" = "$LARGE_FILE_CONTENT" + +git checkout "$PRE_LFS_COMMIT" + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +grep -i "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +git switch - + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +grep -i "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +assert test -f large-file +assert test "$(cat large-file)" = "$LARGE_FILE_CONTENT" diff --git a/system7-tests/integration/case-pureLFS.sh b/system7-tests/integration/case-pureLFS.sh new file mode 100644 index 0000000..91f7430 --- /dev/null +++ b/system7-tests/integration/case-pureLFS.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +git clone github/rd2 pastey/rd2 + +cd pastey/rd2 + +LARGE_FILE_CONTENT="MEGA-LONG-FILE-CONTENT" +echo "$LARGE_FILE_CONTENT" > large-file +assert git lfs track large-file + +assert git add large-file .gitattributes +git commit -m"\"track large file with Git LFS\"" + +git push + + +cd "$S7_ROOT/nik/rd2" + +assert git pull + +assert test -f large-file +assert test "$(cat large-file)" = "$LARGE_FILE_CONTENT" diff --git a/system7-tests/integration/case-switchToLFSBranchInExistingS7Repo.sh b/system7-tests/integration/case-switchToLFSBranchInExistingS7Repo.sh new file mode 100644 index 0000000..4455d5a --- /dev/null +++ b/system7-tests/integration/case-switchToLFSBranchInExistingS7Repo.sh @@ -0,0 +1,84 @@ +#!/bin/sh + +git clone github/rd2 pastey/rd2 + +cd pastey/rd2 + +#COMMIT_WITHOUT_S7="$(git rev-parse HEAD)" + +assert s7 init +assert git add . +assert git commit -m "\"init s7\"" + +assert s7 add --stage Dependencies/RDPDFKit '"$S7_ROOT/github/RDPDFKit"' +git commit -m"add pdfkit subrepo" + +git push + +#COMMIT_WITH_S7="$(git rev-parse HEAD)" + +cd "$S7_ROOT/nik" + +assert git clone "$S7_ROOT/github/rd2" + +cd rd2 + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +assert test -f Dependencies/RDPDFKit/.gitignore + + +cd "$S7_ROOT/pastey/rd2" + +assert git checkout -b git-lfs + +LARGE_FILE_CONTENT="MEGA-LONG-FILE-CONTENT" +echo "$LARGE_FILE_CONTENT" > large-file +assert git lfs track large-file +assert git add large-file .gitattributes + +# re-initialize hooks for both: s7 and LFS +assert s7 init + +git commit -m"\"track large file with Git LFS\"" + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +grep -i "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +git push origin -u HEAD + + +cd "$S7_ROOT/nik/rd2" + +PRE_LFS_COMMIT="$(git rev-parse HEAD)" + +assert git fetch + +assert git switch git-lfs + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +grep -i "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +assert test -f large-file +assert test "$(cat large-file)" = "$LARGE_FILE_CONTENT" + +git checkout "$PRE_LFS_COMMIT" + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +grep -i "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +git switch - + +grep "s7" .git/hooks/post-checkout +assert test 0 -eq $? +grep -i "lfs" .git/hooks/post-checkout +assert test 0 -eq $? + +assert test -f large-file +assert test "$(cat large-file)" = "$LARGE_FILE_CONTENT" diff --git a/system7-tests/integration/test.sh b/system7-tests/integration/test.sh index 846b4f0..a06d1ce 100755 --- a/system7-tests/integration/test.sh +++ b/system7-tests/integration/test.sh @@ -80,6 +80,10 @@ red=$(tput setaf 1) green=$(tput setaf 2) normal=$(tput sgr0) +TOTAL_NUMBER_OF_CASES=${#CASES_ARRAY[@]} + +CURRENT_CASE_NUMBER=0 + setupAndRunCase() { local CASE=$1 @@ -106,8 +110,9 @@ setupAndRunCase() { fi else echo - echo "$CASE" - echo "=============" + echo "[$CURRENT_CASE_NUMBER / $TOTAL_NUMBER_OF_CASES] $CASE" + echo "=======================================" + echo S7_ROOT="$TEST_ROOT" sh -x "$SCRIPT_SOURCE_DIR/$CASE" 2>&1 echo if [ -f "$TEST_ROOT/FAIL" ]; then @@ -130,6 +135,7 @@ do if [ 1 -eq $PARALLELIZE ]; then setupAndRunCase $CASE & else + CURRENT_CASE_NUMBER=$(( CURRENT_CASE_NUMBER + 1 )) setupAndRunCase $CASE fi done diff --git a/system7/Commands/S7BootstrapCommand.m b/system7/Commands/S7BootstrapCommand.m index 53b390a..5609836 100644 --- a/system7/Commands/S7BootstrapCommand.m +++ b/system7/Commands/S7BootstrapCommand.m @@ -46,8 +46,9 @@ - (int)runWithArguments:(NSArray *)arguments { installHook(repo, @"post-checkout", [[self class] bootstrapCommandLine], - NO, - NO); + NO, /* forceOverwrite */ + NO, /* installFakeHooks */ + NO /* duplicateStdin */); } // according to https://git-scm.com/docs/gitattributes @@ -72,10 +73,6 @@ - (BOOL)shouldInstallBootstrap { return NO; } - if ([self willBootstrapConflictWithGitLFS]) { - return NO; - } - if ([NSFileManager.defaultManager fileExistsAtPath:S7ControlFileName]) { // If repo contains .s7control, then user has already done some work in it // which implies that s7 IS initialized in the repo. @@ -111,35 +108,6 @@ - (BOOL)isS7PostCheckoutAlreadyInstalled { return NO; } -- (BOOL)willBootstrapConflictWithGitLFS { - NSError *error = nil; - NSString *gitattributesContent = [[NSString alloc] initWithContentsOfFile:@".gitattributes" encoding:NSUTF8StringEncoding error:&error]; - if (nil != error) { - // Such situation would be really unexpected – how would Git find out - // that it should filter .s7bootstrap if there's no .gitattributes? - // Maybe something wrong with the permissions? - // Anyway, if we cannot read .gitattributes, then we better avoid bootstrap. - // - logError("s7 bootstrap: failed to read contents of .gitattributes file. Error: %s\n", - [[error description] cStringUsingEncoding:NSUTF8StringEncoding]); - return YES; - } - - if ([gitattributesContent containsString:@"filter=lfs"]) { - // this repo contains some LFS files. - // If LFS hook is NOT installed, then we do not install bootstrap hook - // not to cause LFS hook install failure. In such case user will have to - // run `s7 init` manually 🤷‍♂️ - // If LFS hook IS installed, we can still merge-in bootstrap command into it. - // - if (NO == [NSFileManager.defaultManager fileExistsAtPath:@".git/hooks/post-checkout"]) { - return YES; - } - } - - return NO; -} - + (NSString *)bootstrapCommandLine { return @"/usr/local/bin/s7 init"; } diff --git a/system7/Commands/S7InitCommand.h b/system7/Commands/S7InitCommand.h index cb268d4..e42cc69 100644 --- a/system7/Commands/S7InitCommand.h +++ b/system7/Commands/S7InitCommand.h @@ -16,6 +16,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL installFakeHooks; ++ (int)initializeGitLFSIfNecessaryInRepo:(GitRepository *)repo; + @end NS_ASSUME_NONNULL_END diff --git a/system7/Commands/S7InitCommand.m b/system7/Commands/S7InitCommand.m index be1444b..ce6fa86 100644 --- a/system7/Commands/S7InitCommand.m +++ b/system7/Commands/S7InitCommand.m @@ -134,21 +134,18 @@ - (int)runWithArguments:(NSArray *)arguments inRepo:(GitRepository * } } - NSSet> *hookClasses = [NSSet setWithArray:@[ - [S7PrePushHook class], - [S7PostCheckoutHook class], - [S7PostCommitHook class], - [S7PostMergeHook class], - [S7PrepareCommitMsgHook class], - ]]; + if (NO == self.installFakeHooks) { + if (NO == self.forceOverwriteHooks) { + if (0 != [self.class initializeGitLFSIfNecessaryInRepo:repo]) { + logError("failed to install Git LFS hooks!\n"); + return S7ExitCodeGitOperationFailed; + } + } - int hookInstallationExitCode = 0; - for (Class hookClass in hookClasses) { - hookInstallationExitCode = [self installHook:hookClass inRepo:repo]; - if (0 != hookInstallationExitCode) { - logError("error: failed to install `%s` git hook\n", - [hookClass gitHookName].fileSystemRepresentation); - return hookInstallationExitCode; + const int hooksInstallExitCode = [self.class installHooksInRepo:repo + forceOverwrite:self.forceOverwriteHooks]; + if (S7ExitCodeSuccess != hooksInstallExitCode) { + return hooksInstallExitCode; } } @@ -221,7 +218,35 @@ - (int)runWithArguments:(NSArray *)arguments inRepo:(GitRepository * return S7ExitCodeSuccess; } -- (int)installHook:(Class)hookClass inRepo:(GitRepository *)repo { ++ (int)installHooksInRepo:(GitRepository *)repo + forceOverwrite:(BOOL)forceOverwrite +{ + NSSet> *hookClasses = [NSSet setWithArray:@[ + [S7PrePushHook class], + [S7PostCheckoutHook class], + [S7PostCommitHook class], + [S7PostMergeHook class], + [S7PrepareCommitMsgHook class], + ]]; + + for (Class hookClass in hookClasses) { + int hookInstallationExitCode = [self installHook:hookClass + inRepo:repo + forceOverwrite:forceOverwrite]; + if (0 != hookInstallationExitCode) { + logError("error: failed to install `%s` git hook\n", + [hookClass gitHookName].fileSystemRepresentation); + return hookInstallationExitCode; + } + } + + return S7ExitCodeSuccess; +} + ++ (int)installHook:(Class)hookClass + inRepo:(GitRepository *)repo + forceOverwrite:(BOOL)forceOverwrite +{ // there's no guarantee that s7 will be the only one citizen of a hook, // thus we add " || exit $?" – to exit hook properly if s7 hook fails NSString *commandLine = [NSString @@ -229,7 +254,12 @@ - (int)installHook:(Class)hookClass inRepo:(GitRepository *)repo { @"/usr/local/bin/s7 %@-hook \"$@\" <&0 || exit $?", [hookClass gitHookName]]; - return installHook(repo, [hookClass gitHookName], commandLine, self.forceOverwriteHooks, self.installFakeHooks); + return installHook(repo, + [hookClass gitHookName], + commandLine, + forceOverwrite, + NO, /* installFakeHooks */ + [hookClass dependsOnStdin]); } - (int)installS7ConfigMergeDriverInRepo:(GitRepository *)repo { @@ -335,13 +365,9 @@ - (int)createBootstrapFileInRepo:(GitRepository *)repo { "\n" "\n" "NOTE:\n" - " s7 won't install its bootstrap hook if:\n" - " - post-checkout hook exists and it's not a shell script (where we can merge in).\n" - " This is theoretically possible if a user ran clone with custom templates\n" - " - there's *no post-checkout hook yet*, but s7 can see that the repo uses Git LFS,\n" - " thus it can predict that LFS *will* be installing its hooks. If s7 installs\n" - " its bootstrap post-checkout, then Git LFS will fail to install its post-checkout –\n" - " not the situation we want a user to deal with.\n" + " s7 won't install its bootstrap hook if post-checkout hook exists and it's not a shell script" + " (where we can merge in).\n" + " This is theoretically possible if a user ran clone with custom templates.\n" ; if (NO == [[NSFileManager defaultManager] createFileAtPath:bootstrapFilePath @@ -361,4 +387,22 @@ - (int)createBootstrapFileInRepo:(GitRepository *)repo { return S7ExitCodeSuccess; } ++ (int)initializeGitLFSIfNecessaryInRepo:(GitRepository *)repo { + if (NO == [repo isGitLFSRepo]) { + return S7ExitCodeSuccess; + } + + if ([repo isGitLFSProperlyInstalled]) { + return S7ExitCodeSuccess; + } + + logInfo("detected s7 should co-exist with Git LFS. Installing LFS hooks first...\n"); + const int hooksInstallExitCode = [repo installGitLFS]; + if (S7ExitCodeSuccess != hooksInstallExitCode) { + return hooksInstallExitCode; + } + + return [self installHooksInRepo:repo forceOverwrite:NO]; +} + @end diff --git a/system7/Hooks/S7Hook.h b/system7/Hooks/S7Hook.h index e9796a4..1b2682a 100644 --- a/system7/Hooks/S7Hook.h +++ b/system7/Hooks/S7Hook.h @@ -16,6 +16,7 @@ NS_ASSUME_NONNULL_BEGIN @protocol S7Hook + (NSString *)gitHookName; ++ (BOOL)dependsOnStdin; - (int)runWithArguments:(NSArray *)arguments; diff --git a/system7/Hooks/S7PostCheckoutHook.m b/system7/Hooks/S7PostCheckoutHook.m index 68a5241..134fdc5 100644 --- a/system7/Hooks/S7PostCheckoutHook.m +++ b/system7/Hooks/S7PostCheckoutHook.m @@ -25,6 +25,10 @@ + (NSString *)gitHookName { return @"post-checkout"; } ++ (BOOL)dependsOnStdin { + return NO; +} + - (int)runWithArguments:(NSArray *)arguments { logInfo("\ns7: post-checkout hook start\n"); const int result = [self doRunWithArguments:arguments]; @@ -99,6 +103,11 @@ - (int)doRunWithArguments:(NSArray *)arguments { return S7ExitCodeSuccess; } + const int lfsInstallExitCode = [S7InitCommand initializeGitLFSIfNecessaryInRepo:repo]; + if (S7ExitCodeSuccess != lfsInstallExitCode) { + return lfsInstallExitCode; + } + return [self.class checkoutSubreposForRepo:repo fromRevision:fromRevision toRevision:toRevision]; } diff --git a/system7/Hooks/S7PostCommitHook.m b/system7/Hooks/S7PostCommitHook.m index 17901cd..404ef67 100644 --- a/system7/Hooks/S7PostCommitHook.m +++ b/system7/Hooks/S7PostCommitHook.m @@ -16,6 +16,10 @@ + (NSString *)gitHookName { return @"post-commit"; } ++ (BOOL)dependsOnStdin { + return NO; +} + - (int)runWithArguments:(NSArray *)arguments { logInfo("s7: post-commit hook start\n"); const int result = [self doRunWithArguments:arguments]; diff --git a/system7/Hooks/S7PostMergeHook.m b/system7/Hooks/S7PostMergeHook.m index f305353..5fb152e 100644 --- a/system7/Hooks/S7PostMergeHook.m +++ b/system7/Hooks/S7PostMergeHook.m @@ -9,6 +9,7 @@ #import "S7PostMergeHook.h" #import "S7PostCheckoutHook.h" +#import "S7InitCommand.h" @implementation S7PostMergeHook @@ -16,6 +17,10 @@ + (NSString *)gitHookName { return @"post-merge"; } ++ (BOOL)dependsOnStdin { + return NO; +} + - (int)runWithArguments:(NSArray *)arguments { logInfo("\ns7: post-merge hook start\n"); const int result = [self doRunWithArguments:arguments]; @@ -30,6 +35,11 @@ - (int)doRunWithArguments:(NSArray *)arguments { return S7ExitCodeNotGitRepository; } + const int lfsInstallExitCode = [S7InitCommand initializeGitLFSIfNecessaryInRepo:repo]; + if (S7ExitCodeSuccess != lfsInstallExitCode) { + return lfsInstallExitCode; + } + S7Config *controlConfig = [[S7Config alloc] initWithContentsOfFile:S7ControlFileName]; S7Config *postMergeConfig = [[S7Config alloc] initWithContentsOfFile:S7ConfigFileName]; diff --git a/system7/Hooks/S7PrePushHook.m b/system7/Hooks/S7PrePushHook.m index 5df08e2..9f4c31a 100644 --- a/system7/Hooks/S7PrePushHook.m +++ b/system7/Hooks/S7PrePushHook.m @@ -50,7 +50,7 @@ @implementation S7PrePushHook // I used `git log --branches --not --remotes --no-walk --decorate --pretty=format:%S` for some time, // but turned out that it reports some behind branches from time to time (I haven't found an easy way // to reproduce this). I could fix it by removing --no-walk, but I've found another scenario where -// even without --no-walk not all branches are listed – it you merge brances with fast-forward (they +// even without --no-walk not all branches are listed – it you merge branches with fast-forward (they // both point to the same commit), only one of branches is reported by `git log --branches --not --remotes`. // I thought,– "alright – I can list .git/refs/heads and .git/refs/remotes, and build the list // of branches to push by hand". Here comes a new problem – how do you distinct between the new local @@ -65,6 +65,10 @@ + (NSString *)gitHookName { return @"pre-push"; } ++ (BOOL)dependsOnStdin { + return YES; +} + - (NSString *)stdinContents { if (self.testStdinContents) { return self.testStdinContents; diff --git a/system7/Hooks/S7PrepareCommitMsgHook.m b/system7/Hooks/S7PrepareCommitMsgHook.m index e243090..f889a30 100644 --- a/system7/Hooks/S7PrepareCommitMsgHook.m +++ b/system7/Hooks/S7PrepareCommitMsgHook.m @@ -30,6 +30,10 @@ + (NSString *)gitHookName { return @"prepare-commit-msg"; } ++ (BOOL)dependsOnStdin { + return NO; +} + - (int)runWithArguments:(NSArray *)arguments { logInfo("s7: prepare-commit-msg hook start\n"); const int result = [self doRunWithArguments:arguments]; diff --git a/system7/Utils/S7Utils.h b/system7/Utils/S7Utils.h index a719fd1..2077cc2 100644 --- a/system7/Utils/S7Utils.h +++ b/system7/Utils/S7Utils.h @@ -22,15 +22,18 @@ int removeLinesFromGitIgnore(NSSet *linesToRemove); int addLineToGitAttributes(GitRepository *repo, NSString *lineToAppend); int removeFilesFromGitattributes(NSSet *filesToRemove); -int installHook(GitRepository *repo, NSString *hookName, NSString *commandLine, BOOL forceOverwrite, BOOL installFakeHooks); +int installHook(GitRepository *repo, + NSString *hookName, + NSString *commandLine, + BOOL forceOverwrite, + BOOL installFakeHooks, + BOOL duplicateStdin); BOOL isCurrentDirectoryS7RepoRoot(void); BOOL isS7Repo(GitRepository *repo); int s7RepoPreconditionCheck(void); int saveUpdatedConfigToMainAndControlFile(S7Config *updatedConfig); -NSString *_Nullable getGlobalGitConfigValue(NSString *key); - BOOL S7ArgumentMatchesFlag(NSString *argument, NSString *longFlag, NSString *shortFlag); #define S7_REPO_PRECONDITION_CHECK() \ diff --git a/system7/Utils/S7Utils.m b/system7/Utils/S7Utils.m index a93f71c..43ac4bc 100644 --- a/system7/Utils/S7Utils.m +++ b/system7/Utils/S7Utils.m @@ -287,28 +287,23 @@ int saveUpdatedConfigToMainAndControlFile(S7Config *updatedConfig) { return S7ExitCodeSuccess; } -NSString *getGlobalGitConfigValue(NSString *key) { - NSCParameterAssert(key != nil); - NSString *const launch = [NSString stringWithFormat:@"git config --global --get %@", key]; - FILE *const proc = popen([launch cStringUsingEncoding:NSUTF8StringEncoding], "r"); - if (proc == NULL) { - return nil; +static BOOL isHookCommandAlreadyInstalled(NSString *existingHookContents, NSString *hookCommandLine) { + if ([existingHookContents containsString:hookCommandLine]) { + return YES; } - - NSMutableString *const value = [NSMutableString new]; - char buffer[16]; - while (fgets(buffer, sizeof(buffer) / sizeof(char), proc)) { - [value appendFormat:@"%s", buffer]; - } - - if (pclose(proc) != 0) { - return nil; - } - - return [value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + + hookCommandLine = [hookCommandLine stringByReplacingOccurrencesOfString:@"<&0" withString:@"<\"$REFS\""]; + + return [existingHookContents containsString:hookCommandLine]; } -int installHook(GitRepository *repo, NSString *hookName, NSString *commandLine, BOOL forceOverwrite, BOOL installFakeHooks) { +int installHook(GitRepository *repo, + NSString *hookName, + NSString *commandLine, + BOOL forceOverwrite, + BOOL installFakeHooks, + BOOL duplicateStdin) +{ if (installFakeHooks) { return S7ExitCodeSuccess; } @@ -327,7 +322,7 @@ int installHook(GitRepository *repo, NSString *hookName, NSString *commandLine, return S7ExitCodeFileOperationFailed; } - if ([existingContents containsString:commandLine]) { + if (isHookCommandAlreadyInstalled(existingContents, commandLine)) { return S7ExitCodeSuccess; } @@ -353,6 +348,45 @@ int installHook(GitRepository *repo, NSString *hookName, NSString *commandLine, commandLine, existingHookBody]; + if (duplicateStdin) { + NSString *stdinMarker = @"<&0"; + const NSRange firstOccurrenceRange = [mergedHookContents rangeOfString:stdinMarker]; + if (firstOccurrenceRange.location != NSNotFound) { + const NSRange anyOtherOccurrenceRange = [mergedHookContents rangeOfString:stdinMarker options:NSBackwardsSearch]; + if (anyOtherOccurrenceRange.location != firstOccurrenceRange.location) { + // two or more commands need stdin + + NSString *copyStdinToFile = + @"REFS=$(mktemp)\n" + "cleanup() {\n" + " rm -f \"$REFS\" 2>/dev/null\n" + "}\n" + "\n" + "cleanup\n" + "\n" + "trap cleanup EXIT\n" + "\n" + "while read LINE; do\n" + " echo \"$LINE\" >> \"$REFS\"\n" + "done\n"; + + mergedHookContents = [NSString stringWithFormat: + @"#!/bin/sh\n" + "\n" + "%@" + "\n" + "%@\n" + "\n" + "%@", + copyStdinToFile, + commandLine, + existingHookBody]; + + mergedHookContents = [mergedHookContents stringByReplacingOccurrencesOfString:stdinMarker withString:@"<\"$REFS\""]; + } + } + } + contentsToWrite = mergedHookContents; } } diff --git a/system7/git/Git.h b/system7/git/Git.h index 4044914..ebbf493 100644 --- a/system7/git/Git.h +++ b/system7/git/Git.h @@ -134,6 +134,12 @@ NS_ASSUME_NONNULL_BEGIN - (int)getRemote:(NSString * _Nullable __autoreleasing * _Nonnull)ppRemote; - (int)getUrl:(NSString * _Nullable __autoreleasing * _Nonnull)ppUrl; +#pragma mark - LFS - + +- (BOOL)isGitLFSRepo; +- (BOOL)isGitLFSProperlyInstalled; +- (int)installGitLFS; + @end NS_ASSUME_NONNULL_END diff --git a/system7/git/Git.m b/system7/git/Git.m index e2a12ee..74ab815 100644 --- a/system7/git/Git.m +++ b/system7/git/Git.m @@ -1297,6 +1297,128 @@ - (int)commitWithMessage:(NSString *)message { stdErrOutput:NULL]; } +#pragma mark - LFS - + +- (BOOL)isGitLFSRepo { + NSString *gitAttributesPath = [self.absolutePath stringByAppendingPathComponent:@".gitattributes"]; + if (NO == [NSFileManager.defaultManager fileExistsAtPath:gitAttributesPath]) { + return NO; + } + + NSError *error = nil; + NSString *gitattributesContent = [[NSString alloc] + initWithContentsOfFile:gitAttributesPath + encoding:NSUTF8StringEncoding + error:&error]; + if (nil != error) { + // Such situation would be really unexpected – how would Git find out + // that it should filter .s7bootstrap or merge .s7substate if there's no .gitattributes? + // Maybe something wrong with the permissions? + // Anyway, if we cannot read .gitattributes, then there's no Git LFS either. + // + logError("failed to read contents of .gitattributes file. Error: %s\n", + [[error description] cStringUsingEncoding:NSUTF8StringEncoding]); + return NO; + } + + if ([gitattributesContent containsString:@"filter=lfs"]) { + return YES; + } + + return NO; +} + +- (BOOL)isGitLFSProperlyInstalled { + for (NSString *hookName in @[@"pre-push", + @"post-checkout", + @"post-commit", + @"post-merge"]) + { + NSString *absoluteHookPath = [[self.absolutePath stringByAppendingPathComponent:@".git/hooks"] stringByAppendingPathComponent:hookName]; + + if (NO == [NSFileManager.defaultManager fileExistsAtPath:absoluteHookPath]) { + logInfo("%s doesn't exist.\n", hookName.UTF8String); + return NO; + } + + NSError *error = nil; + NSString *hookContent = [[NSString alloc] initWithContentsOfFile:absoluteHookPath encoding:NSUTF8StringEncoding error:&error]; + if (nil != error) { + logError("failed to read '%s' content. Error: %s\n", hookName.UTF8String, error.description.UTF8String); + return NO; + } + + NSString *minimalNecessaryHookContents = [NSString stringWithFormat:@"git lfs %@ \"$@\" <&0", hookName]; + if (NO == [hookContent containsString:minimalNecessaryHookContents]) { + logInfo("%s doesn't contain `git lfs` calls.\n", hookName.UTF8String); + return NO; + } + } + + return YES; +} + +- (int)installGitLFS { + // This repo contains some LFS files. + // To work properly, Git LFS needs its hooks. + // Git LFS cannot mix its hooks contents into any existing hooks. + // But S7 can. + // So we will do LFS a favour, and install its hooks first. + // + // At first we tried to install LFS hooks by calling `git lfs install --force`, while we are in our Bootstrap filter. + // This didn't work well, cause as `git lfs install` produces some stdout output. Bootstrap is a Git Filter + // implementation, so everything that goes to stdout, actually is the filter result, i.e. it goes to the + // .s7bootstrap file. We don't want that. + // + // Ok, we called `git lfs install --force` in S7InitCommand, which is called by our bootstrap post-checkout hook. + // This had an unexpected result too. `git lfs install` opens a hook file(s) for append and writes to them directly. + // This means, that while the bootstrap post-checkout hook is being executed, `git lfs` appends its code to the + // hook file. Shell continues the hook execution from the position where it finished, and stumbles on the middle + // of the Git LFS hook code. To properly install hooks, LFS should do this atomically. But they don't. + // + // One more gotcha is that LFS installs its hooks in a loop, and if any hook installation fails, it stops. + // We used to check if the pre-push hook contains any traces of LFS and consider that to denote that LFS + // has successfully installed its hooks. But it means just that the pre-push has been installed, and three + // others might be not. So, we have to check all LFS hooks, to be sure that they are installed properly. + // + // All this leads to the need for us to *manually* install Git LFS hooks ourselves. + // We can either hardcode them in our code, or run `git lfs install --manual` and parse its output. + // `--manual` seems to be better, but still we have to pray that the format of the `--manual` output + // will stay the same. So, both ways are more or less equally bad. At least, hardcoding won't require + // any parsing. + // + // We will hardcode the hooks for now and regret that in the future in the worst case scenario. + // + + for (NSString *hookName in @[@"pre-push", + @"post-checkout", + @"post-commit", + @"post-merge"]) + { + NSString *commandLine = + [NSString + stringWithFormat:@"command -v git-lfs >/dev/null 2>&1 || { printf >&2 \"\\n%%s\\n\\n\" \"This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the '%@' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\"; exit 2; }\ngit lfs %@ \"$@\" <&0\n", + hookName, hookName]; + const int hookInstallResult = installHook(self, + hookName, + commandLine, + YES, /* force overwrite */ + NO, /* install fake hooks */ + NO /* duplicate stdin */); + if (0 != hookInstallResult) { + logError("error: failed to install `%s` git hook\n", + hookName.fileSystemRepresentation); + return hookInstallResult; + } + } + + // but we still have to call `git lfs install` for it to install filters, repositoryformatversion, etc. + return [self + runGitWithArguments:@[@"lfs", @"install", @"--skip-repo"] + stdOutOutput:NULL + stdErrOutput:NULL]; +} + @end #pragma mark - utils for tests -