├── .github ├── FUNDING.yml ├── build └── workflows │ ├── ci.yaml │ ├── publish-aur.yaml │ ├── release.yaml │ └── virustotal.yaml ├── .gitignore ├── .mob ├── CHANGELOG.md ├── CNAME ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _config.yml ├── _layouts └── default.html ├── coauthors.go ├── coauthors_test.go ├── configuration ├── configuration.go └── configuration_test.go ├── diagram.svg ├── diagram_def.svg ├── docs └── architecture.puml ├── favicon.ico ├── favicon_def.ico ├── find_next.go ├── find_next_test.go ├── go.mod ├── goal └── goal.go ├── help └── help.go ├── httpclient └── httpclient.go ├── install ├── install.cmd ├── install.sh ├── logo.svg ├── logo_def.svg ├── mob-configuration-example ├── mob-social-media-preview-def.png ├── mob-social-media-preview.png ├── mob.go ├── mob.nix ├── mob_test.go ├── open ├── open.go ├── open_darwin.go ├── open_unix.go └── open_windows.go ├── reinstall ├── say └── say.go ├── snap ├── local │ ├── mob-launcher │ └── say └── snapcraft.yaml ├── squash_wip.go ├── squash_wip_test.go ├── status.go ├── status_test.go ├── test └── test.go ├── timer.go └── timer_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [simonharrer] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eux 3 | 4 | PROJECT_NAME=$(basename $GITHUB_REPOSITORY) 5 | ARCH=${GOARCH} 6 | if [ $GOOS == 'darwin' ]; then 7 | ARCH="universal" 8 | fi 9 | NAME="${PROJECT_NAME}_${VERSION}_${GOOS}_${ARCH}" 10 | 11 | EXT='' 12 | 13 | if [ $GOOS == 'windows' ]; then 14 | EXT='.exe' 15 | fi 16 | 17 | tar cvfz ${NAME}.tar.gz "${PROJECT_NAME}${EXT}" LICENSE 18 | shasum -a 256 ${NAME}.tar.gz | cut -d ' ' -f 1 > ${NAME}_sha256_checksum.txt 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | os: [ubuntu-latest, macos-latest] 9 | git-version: [latest, 2.37.0] 10 | include: 11 | - os: ubuntu-latest 12 | git-version: 2.20.0 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Install old git version (macOS) 17 | if: ${{ matrix.os == 'macos-latest' && matrix.git-version != 'latest'}} 18 | run: | 19 | brew tap-new mob/local-git 20 | brew extract --version=${{ matrix.git-version }} git mob/local-git 21 | brew install git@${{ matrix.git-version }} || brew link --overwrite git@${{ matrix.git-version }} 22 | - name: Install old git version (ubuntu) 23 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.git-version != 'latest'}} 24 | run: | 25 | sudo apt-get update 26 | sudo apt-get install gettext asciidoc docbook2x 27 | curl https://mirrors.edge.kernel.org/pub/software/scm/git/git-${{ matrix.git-version }}.tar.gz --output git-${{ matrix.git-version }}.tar.gz 28 | tar -zxf git-${{ matrix.git-version }}.tar.gz 29 | cd git-${{ matrix.git-version }} 30 | make configure 31 | ./configure --prefix=/usr 32 | make all info 33 | sudo make install install-info 34 | - name: Show git version 35 | run: git version 36 | - name: Use Go 1.22.x 37 | uses: actions/setup-go@v5 38 | with: 39 | go-version: '1.22' 40 | - name: Test 41 | run: go test ./... -v 42 | -------------------------------------------------------------------------------- /.github/workflows/publish-aur.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to AUR 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish-aur: 8 | runs-on: ubuntu-latest 9 | env: 10 | PACKAGE_NAME: mobsh-bin 11 | VERSION: v5.2.0 # Set this to the tag name of the release 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Download checksum file 17 | run: | 18 | wget -O checksum.txt https://github.com/remotemobprogramming/mob/releases/download/${{ env.VERSION }}/mob_${{ env.VERSION }}_linux_amd64_sha256_checksum.txt 19 | SHA_LINUX=$(cat checksum.txt) 20 | echo "SHA_LINUX=$SHA_LINUX" >> $GITHUB_ENV 21 | 22 | - name: Set VERSION_NUMBER 23 | run: | 24 | PKGVER="${{ env.VERSION }}" 25 | PKGVER="${PKGVER#v}" # Strip 'v' at the start 26 | echo "PKGVER=$PKGVER" >> $GITHUB_ENV 27 | 28 | - name: Create PKGBUILD 29 | run: | 30 | mkdir -p ./aur/${{ env.PACKAGE_NAME }}/ 31 | cat > ./aur/${{ env.PACKAGE_NAME }}/PKGBUILD << 'EOF' 32 | pkgname=${{ env.PACKAGE_NAME }} 33 | pkgver=${{ env.PKGVER }} 34 | pkgrel=1 35 | pkgdesc="Fast git handover with mob" 36 | arch=('x86_64') 37 | url="https://github.com/${{ github.repository }}" 38 | license=('MIT') 39 | depends=("git") 40 | optdepends=('espeak-ng-espeak: Multi-lingual software speech synthesizer' 41 | 'mbrola-voices-us1: An American English female voice for the MBROLA synthesizer') 42 | provides=('mobsh') 43 | conflicts=('mobsh' 'mob') 44 | source_x86_64=("https://github.com/remotemobprogramming/mob/releases/download/${{ env.VERSION }}/mob_${{ env.VERSION }}_linux_amd64.tar.gz") 45 | sha256sums_x86_64=("${{ env.SHA_LINUX }}") 46 | package() { 47 | install -D -m644 "LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE" 48 | install -D -m755 mob_linux_amd64 "\$pkgdir/usr/bin/mob" 49 | } 50 | EOF 51 | 52 | - name: Store PKGBUILD as an artifact 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: PKGBUILD 56 | path: ./aur/${{ env.PACKAGE_NAME }}/PKGBUILD 57 | 58 | - name: Publish AUR package 59 | uses: KSXGitHub/github-actions-deploy-aur@v2.2.5 60 | with: 61 | pkgname: ${{ env.PACKAGE_NAME }} 62 | pkgver: ${{ env.PKGVER }} 63 | pkgbuild: ./aur/${{ env.PACKAGE_NAME }}/PKGBUILD 64 | commit_username: ${{ secrets.AUR_USERNAME }} 65 | commit_email: ${{ secrets.AUR_EMAIL }} 66 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 67 | commit_message: "Update AUR package for ${{ env.VERSION }}" 68 | ssh_keyscan_types: rsa,ecdsa,ed25519 69 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | name: Release 5 | jobs: 6 | release: 7 | strategy: 8 | matrix: 9 | include: 10 | - os: ubuntu-latest 11 | goos: linux 12 | - os: ubuntu-latest 13 | goos: windows 14 | - os: macos-latest 15 | goos: darwin 16 | env: 17 | GOOS: ${{ matrix.goos }} 18 | CGO_ENABLED: 0 19 | name: release 20 | runs-on: ${{ matrix.os }} 21 | outputs: 22 | sha_linux: ${{ steps.shasum.outputs.sha_linux }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Use Go 1.22.x 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: '1.22' 29 | - name: Test 30 | if: ${{ matrix.goos != 'windows' }} 31 | run: go test ./... -v 32 | env: 33 | GOOS: ${{ matrix.goos }} 34 | - name: Build ${{ matrix.goos }} amd64 35 | if: ${{ matrix.goos != 'darwin' }} 36 | run: go build -o mob${{ matrix.goos == 'windows' && '.exe' || '' }} 37 | env: 38 | GOOS: ${{ matrix.goos }} 39 | GOARCH: amd64 40 | - name: Build macOS universal 41 | if: ${{ matrix.goos == 'darwin' }} 42 | run: > 43 | GOARCH=amd64 go build -o mob_amd64 && GOARCH=arm64 go build -o mob_arm64 && lipo -create -output mob mob_amd64 mob_arm64 44 | env: 45 | GOOS: ${{ matrix.goos }} 46 | - name: VirusTotal Scan 47 | uses: crazy-max/ghaction-virustotal@v3 48 | with: 49 | vt_api_key: ${{ secrets.VT_API_KEY }} 50 | update_release_body: true 51 | files: ./mob${{ matrix.goos == 'windows' && '.exe' || '' }} 52 | - name: Create release artifacts using .github/build 53 | run: .github/build 54 | env: 55 | GOARCH: amd64 56 | VERSION: ${{ github.event.release.tag_name }} 57 | - name: Upload artifacts to release 58 | uses: svenstaro/upload-release-action@v2 59 | with: 60 | repo_token: ${{ secrets.GITHUB_TOKEN }} 61 | file: "*{.tar.gz,_checksum.txt}" 62 | tag: ${{ github.event.release.tag_name }} 63 | file_glob: true 64 | - name: Set SHA 65 | if: matrix.goos == 'darwin' || matrix.goos == 'linux' 66 | id: shasum 67 | run: | 68 | echo "sha_${{ matrix.goos }}=\"$(shasum -a 256 *.tar.gz | awk '{printf $1}')\"" >> $GITHUB_OUTPUT 69 | - name: Set up Homebrew 70 | if: matrix.goos == 'darwin' 71 | id: set-up-homebrew 72 | uses: Homebrew/actions/setup-homebrew@master 73 | - name: Bump homebrew formula 74 | if: matrix.goos == 'darwin' 75 | env: 76 | HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW }} 77 | run: | 78 | git config --global user.email "ci@example.com" 79 | git config --global user.name "CI" 80 | brew tap ${{github.repository_owner}}/homebrew-brew 81 | brew bump-formula-pr -f --version=${{ github.event.release.tag_name }} --no-browse --no-audit \ 82 | --sha256=${{ steps.shasum.outputs.sha_darwin }} \ 83 | --url="https://github.com/${{github.repository_owner}}/mob/releases/download/${{ github.event.release.tag_name }}/mob_${{ github.event.release.tag_name }}_darwin_universal.tar.gz" \ 84 | ${{github.repository_owner}}/homebrew-brew/mob 85 | 86 | publish-arch-linux-package: 87 | needs: [release] 88 | name: Publish Arch Linux package 89 | runs-on: ubuntu-latest 90 | env: 91 | PACKAGE_NAME: mobsh-bin 92 | steps: 93 | - name: Create PKGBUILD 94 | run: | 95 | # Create the output directory 96 | mkdir -p ./aur/${{ env.PACKAGE_NAME }}/ 97 | 98 | # Strip the leading "v" from the version 99 | PACKAGE_VERSION="$(echo "${{ github.event.release.tag_name }}" | sed -e 's/^v//')" 100 | 101 | # Output PKGBUILD 102 | cat > ./aur/${{ env.PACKAGE_NAME }}/PKGBUILD << EOF 103 | # Where to file issues: https://github.com/${{ github.repository }}/issues 104 | 105 | pkgname=${{ env.PACKAGE_NAME }} 106 | pkgver=$PACKAGE_VERSION 107 | pkgrel=1 108 | pkgdesc="Fast git handover with mob" 109 | arch=('x86_64') 110 | url="https://github.com/${{ github.repository }}" 111 | license=('MIT') 112 | depends=("git") 113 | optdepends=('espeak-ng-espeak: Multi-lingual software speech synthesizer' 114 | 'mbrola-voices-us1: An American English female voice for the MBROLA synthesizer') 115 | provides=('mobsh') 116 | conflicts=('mobsh' 'mob') 117 | 118 | source_x86_64=("\$url/releases/download/${{ github.event.release.tag_name }}/mob_${{ github.event.release.tag_name }}_linux_amd64.tar.gz") 119 | sha256sums_x86_64=("${{ needs.release.outputs.sha_linux }}") 120 | 121 | package() { 122 | install -D -m644 "LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE" 123 | install -D -m755 "mob" "\$pkgdir/usr/bin/mob" 124 | } 125 | 126 | EOF 127 | - name: Store PKGBUILD as an artifact 128 | uses: actions/upload-artifact@v4 129 | with: 130 | name: PKGBUILD 131 | path: ./aur/${{ env.PACKAGE_NAME }}/PKGBUILD 132 | - name: Publish AUR package 133 | uses: KSXGitHub/github-actions-deploy-aur@v2.2.5 134 | with: 135 | pkgname: ${{ env.PACKAGE_NAME }} 136 | pkgbuild: ./aur/${{ env.PACKAGE_NAME }}/PKGBUILD 137 | commit_username: ${{ secrets.AUR_USERNAME }} 138 | commit_email: ${{ secrets.AUR_EMAIL }} 139 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 140 | commit_message: Update AUR package for ${{ github.event.release.tag_name }} 141 | ssh_keyscan_types: rsa,ecdsa,ed25519 142 | -------------------------------------------------------------------------------- /.github/workflows/virustotal.yaml: -------------------------------------------------------------------------------- 1 | name: VirusTotal Scan 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | virustotal: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - 10 | name: Checkout 11 | uses: actions/checkout@v4 12 | - 13 | name: Set up Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: '1.22' 17 | - 18 | name: Build 19 | run: | 20 | GOOS=windows GOARCH=amd64 go build -o ./mob-virustotal-windows.exe -v 21 | GOOS=linux GOARCH=amd64 go build -o ./mob-virustotal-linux -v 22 | GOOS=darwin GOARCH=amd64 go build -o ./mob-virustotal-macos-amd -v 23 | GOOS=darwin GOARCH=arm64 go build -o ./mob-virustotal-macos-arm -v 24 | - 25 | name: VirusTotal Scan 26 | uses: crazy-max/ghaction-virustotal@v4 27 | with: 28 | vt_api_key: ${{ secrets.VT_API_KEY }} 29 | files: | 30 | ./mob-virustotal-windows.exe 31 | ./mob-virustotal-linux 32 | ./mob-virustotal-macos-amd 33 | ./mob-virustotal-macos-arm -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | mob 2 | *.swp 3 | .DS_Store 4 | mob.exe 5 | .idea 6 | mob.test 7 | /cover.out 8 | *.tar.gz 9 | *_checksum.txt 10 | mob.sh 11 | *.snap 12 | -------------------------------------------------------------------------------- /.mob: -------------------------------------------------------------------------------- 1 | MOB_TIMER_ROOM="mob" 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 5.4.0 2 | - Feature: Add shortcut for `mob start --create` as `mob start -c`. 3 | 4 | Thanks to @TimsDevCorner for your contributions! 5 | 6 | # 5.3.3 7 | - Fix: `mob start` now functions correctly on WIP branches when the base branch is not checked out, applicable to branch names that do not contain a `-` character. 8 | 9 | # 5.3.2 10 | - Fix: Removed wrong warning about diverging wip branch when joining a new session 11 | 12 | # 5.3.1 13 | - Fix: Added documentation for `mob start --discard-uncommitted-changes` in the `mob help` command 14 | 15 | # 5.3.0 16 | - Feature: `mob start --discard-uncommitted-changes` allows to discard uncommitted changes and then starting a new mob session 17 | 18 | Thank you @stefanscheidt for this feature request 19 | 20 | # 5.2.0 21 | - Feature: `mob done` now pulls when someone else already did `done` 22 | 23 | # 5.1.1 24 | - Fix: `mob break 5` is now correctly parsed 25 | 26 | # 5.1.0 27 | - Feature: Adds new flag `--join` for `mob start` to join an existing session 28 | `mob start --join` (#437) 29 | 30 | # 5.0.1 31 | - Fix: The configuration option `MOB_SKIP_CI_PUSH_OPTION_ENABLED` now works correctly 32 | 33 | Thank you @stefanscheidt for reporting this issue 34 | 35 | # 5.0.0 36 | - Feature: You can now set goals 37 | - Feature: Can make use of the git push-option "ci.skip" 38 | - Removed: `mob start` does not create an emtpy commit on a new wip branch anymore 39 | - Feature: Prints git commands before they are finished for faster feedback 40 | - Feature: Added cli option `--room` to set the room name of timer.mob.sh once 41 | - Feature: `mob status` shows a hint if there is no remote branch 42 | - Feature: `mob status` shows the duration of wip branches 43 | - Fix: `mob done --squash-wip` now squashes the start commit as well 44 | - Fix: Fixes a bug with timers smaller than 1 45 | - Windows Performance is improved due to use of `os.UserHomeDir()` 46 | - Upgrade to go 1.22 47 | 48 | Thank you @schmonz for fixing the build on non-Linux Unix systems 49 | Thank you @jmbockhorst for improving windows performance 50 | Thank you @michael-mader for improving our readme 51 | 52 | # 4.5.0 53 | - Removes feature which cancels running timers as this can lead to longer rotations if the codebase is switched. The way it was implemented is also not ideal for virus detection. 54 | - Correct typo in the hint for creating a remote branch 55 | 56 | Thank you @clemlatz for finding and fixing the typo 57 | Thank you @stefanscheidt for updating the readme on the build and install from source instructions 58 | 59 | # 4.4.6 60 | 61 | - Fix: Able to open last modified file with space in path 62 | - Fix: `start` ignores git hooks 63 | - Removes deprecated io/ioutil 64 | - Improves uninstallation instructions 65 | 66 | Thanks to @dkbnz for improving the uninstallation instructions 67 | Thank you @testwill for removing the deprecated ioutil 68 | Thank you @kriber82 for allowing filenames with spaces 69 | 70 | # 4.4.5 71 | - Fix: version 72 | 73 | # 4.4.4 74 | - Fix: missing SHA in Arch PKGBUILD 75 | - Fix: missing glibc on older linux version 76 | 77 | # 4.4.3 78 | - Fix: mob.sh now specifies the remote ref when pushing to allow for custom `git config push.default` settings such as `tracking` 79 | - Fix: Improves the suggested command when you have uncommitted changes on an attempt to open a custom wip branch using `mob start -b green` 80 | - mob.sh now uses the go version 1.20.x 81 | 82 | Thanks to @jstoneham for fixing the bug with using a custom `push.default` setting! 83 | Thank you @tobiasehlert for fixing our GitHub Action workflows post-31st May 2023! 84 | 85 | # 4.4.2 86 | - Fix: `mob config`, `mob help`, `mob moo` and `mob version` are now working outside of git repositories again 87 | - Fix: `mob clean` now works correctly 88 | 89 | Thanks to @danilo-bc and @jrdodds for reporting these bugs! 90 | 91 | # 4.4.1 92 | - Fix: mob.sh now works correctly if git option `status.branch` is set to `true` 93 | 94 | # 4.4.0 95 | - Feature: `mob timer open` opens the timer website in your default web browser. If you specified `MOB_TIMER_ROOM` it will automatically open the specific timer page. 96 | - Feature: mob.sh is now built as a mac universal app and therefore supports Apple Silicon natively. 97 | 98 | # 4.3.1 99 | - Fix: `mob done` will now print the command `git merge --squash --ff mob/main` just once and not twice. 100 | 101 | # 4.3.0 102 | - Feature: Adds `MOB_START_COMMIT_MESSAGE` config property for changing the commit message on `mob start` empty commit introduced in 4.2.0. This should help overcome problems with pre-receive git hooks. 103 | 104 | # 4.2.0 105 | - Feature: mob.sh now starts a mob session with an empty commit to skip CI when creating a new remote branch for the session. The commit is squashed or dropped when `mob done` except for `--no-squash` option. 106 | 107 | # 4.1.2 108 | - Fix: `mob done --squash-wip` won't lose changes when you forget to `mob start` 109 | 110 | # 4.1.1 111 | - Fix: mob showed the wrong executable name in windows 112 | - Feature: mob.sh now makes use of `--push-option=ci.skip` when pushing 113 | - Feature: mob.sh now warns you when your git version is too old 114 | 115 | Thanks to @balintf and @muskacirca for your contributions! 116 | 117 | # 4.0.0 118 | - **NEW** Feature: `mob reset` doesn't reset the mob branch anymore. It now warns you that it deletes the mob branch for everyone and if you want to continue do `mob reset --delete-remote-wip-branch`. 119 | - **NEW** Feature: `mob timer`, `mob break`, `mob start`, `mob next` and `mob done` will stop already running local timers. 120 | - Feature: `mob start --create` will create the remote branch and start the mob session on that branch. 121 | - Feature: mob.sh now also works with tools like `git-repo` which symlink the `.git` folder 122 | - Removed `MOB_START_INCLUDE_UNCOMMITTED_CHANGES` environment variable (has been deprecated for some time). 123 | - `MOB_DONE_SQUASH` no longer supports a boolean value, you have to use the proper values `squash`, `no-squash` or `squash-wip` instead. 124 | 125 | Thanks to @rustiever, @gulio-python, @freekk, and @sebsprenger for your contributions! 126 | 127 | # 3.2.0 128 | - Fix: `mob done --squash-wip` won't fail if a previously committed file has uncommitted modifications. 129 | - Feature: `MOB_TIMER_INSECURE=true` allows enterprises to use the timer.mob.sh companion service despite SSL issues. 130 | 131 | Thanks to @gregorriegler and @JanMeier1337 for your contributions! 132 | 133 | # 3.1.5 134 | - Add a more specific error message if `git` is not installed. 135 | - Allow for using `mob timer` outside of git repositories. 136 | - Fix: `mob done --squash-wip` now successfully auto-merges auto-mergeable diverging changes. 137 | - Print the help output whenever any kind of help argument (`help`, `--help`, `-h`) is present in the command, e.g. `mob s 10 -h`. 138 | - Adds a warning to `mob start` in case the wip branch diverges from the main branch. 139 | - Various fixes in suggestion of next typist: 140 | - Show list of last committers only if there are any. 141 | - Skip suggestions if there has only been a single person so far. 142 | - Consider the case of a new typist joining the session. 143 | - Fix reporting on first commit. 144 | - Show a deprecation warning when MOB_DONE_SQUASH is set to `true` or `false` in the environment variable or in the .mob configuration file. 145 | 146 | # 3.1.4 147 | - Fixes a bug where mob saves the wrong filepath of the last modfied file in the wip commit message. 148 | 149 | # 3.1.3 150 | Just a version bump. 151 | 152 | # 3.1.2 153 | - Fixes a bug where mob hides output of interactive git hooks when `MOB_GIT_HOOKS_ENABLED=true`. And without output, the user doesn't know what to input. And without input, mob waits indefinitely. 154 | 155 | # 3.1.1 156 | - Fixes a bug where `mob clean` failed to delete an orphaned wip branch because of unmerged commits. 157 | 158 | Thanks to @jdrst for making this release possible! 159 | 160 | # 3.1.0 161 | - Add `mob clean` command that removes orphan wip branches that might be a left over of someone else doing a `mob done`. This is especially helpful when using a lot of feature branches. If you call `mob clean` on an orphan wip branch, it will switch you to the base branch, falling back to main/master if the base branch does not exist. 162 | - The values `squash`, `no-squash` and `squash-wip` for MOB_DONE_SQUASH can be set not only via env var, but now in the .mob configuration file as well. 163 | 164 | Thanks to @hollesse and @thmuch making this release possible! 165 | 166 | # 3.0.0 167 | - **NEW** Mob will automatically open the last modified file of the previous typist in your preferred IDE. Therefore, you need to set the configuration option `MOB_OPEN_COMMAND` to a command which opens your IDE. For example, the open command for IntelliJ is `idea %s` 168 | - Add a Smiley face to Happy Collaborating message to make your day :) 169 | 170 | Thanks to @hollesse and @aryanfaghihi making this release possible! 171 | 172 | # 2.6.0 173 | - **NEW** Allow keeping manual commits while squashing all wip commits by finishing a mob-session with `mob done --squash-wip`. 174 | - Removed experimental command `mob squash-wip` in favor of new `mob done --squash-wip`. 175 | - Added missing configuration option `MOB_WIP_BRANCH_PREFIX` for `.mob` file. 176 | 177 | Thanks to @gregorriegler and @hollesse making this release possible! 178 | 179 | # 2.5.0 180 | - Enable git hooks with `MOB_GIT_HOOKS_ENABLED=true`. By default, this option is false and no git hooks such as `pre-commit` or `pre-push` are triggered via mob itself. 181 | 182 | # 2.4.0 183 | - As an alternative to the environment variables, you can configure the mob tool with a `.mob` file in your home directory. For an example have a look at `mob-configuration-example` file. 184 | - As an alternative to the environment variables, you can configure the mob tool with a `.mob` file in your git project root directory. The configuration options `MOB_VOICE_COMMAND`, `MOB_VOICE_MESSAGE`, `MOB_NOTIFY_COMMAND`, and `MOB_NOTIFY_MESSAGE` are disabled for the project specific configuration to prevent bash injection attacks. 185 | Thanks to @vrpntngr & @hollesse making this release possible as part of the @INNOQ Hands-On Event, February 2022. 186 | 187 | # 2.3.0 188 | - With `export MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=true` the room name is automatically derived from the value you passed in via the `mob start --branch ` parameter. 189 | 190 | # 2.2.0 191 | - When mob encounters unpushed commits in the base branch, the tool now provides help for the user to fix this immediately. 192 | - Improves console output when using `mob break 5` in combination with https://timer.mob.sh 193 | - Gives the user the chance to name their final commit after `mob done --no-squash` 194 | 195 | # 2.1.0 196 | - When having set `MOB_TIMER_ROOM` the local timer keeps on working. To disable the local timer altogether, please disable it via `export MOB_TIMER_LOCAL=false`. 197 | - Improved message when nothing to commit (Thanks to @seanpoulter for https://github.com/remotemobprogramming/mob/pull/202) 198 | 199 | # 2.0.0 200 | - **NEW** create a team room on https://timer.mob.sh to have a team timer. Set `MOB_TIMER_ROOM` to the name of your team room and mob will automatically send timer requests to your team room. Mob now even supports breaks with `mob break `. 201 | 202 | # 1.12.0 203 | - If you renamed the executable or use a symlink to use a different name, `mob` will detect the new name and use that in its console output. 204 | - Improves error handling when using `mob start -i`. When the working directory is a subdirectory that would be removed due to `git stash` the mob tool will tell the user about this and aborts with an error. 205 | 206 | # 1.11.1 207 | - Fixes a bug which let `mob version` fail when run outside of a git repository. 208 | 209 | # 1.11.0 210 | - Allow to override the text in the notification and the voice via environment variables `MOB_NOTIFY_MESSAGE` and `MOB_VOICE_MESSAGE`. 211 | - Allow to override the stash name used for stashing uncommitted changes via the environment variable `MOB_STASH_NAME`. 212 | - Allow to override the cli name of the tool via `MOB_CLI_NAME` so you can use `pair`, `ensemble`, `team`, or whatever you like best, instead of `mob`. Just install the `mob` tool, set an alias in your cli and set the environment variable `MOB_CLI_NAME` to the name of your alias. 213 | 214 | # 1.10.0 215 | - Print current time after mob start. This helps when scrolling through the terminal to distinguish the mob start calls. 216 | 217 | # 1.9.0 218 | - Show commit hash of WIP commits made by the `mob` tool on the console. 219 | - `mob start --include-uncommitted-changes` now fails fast. That means, if `mob` can detect any issue preventing it to succeed, it will exit BEFORE calling `git stash`. This will make error recovery much easier as one doesn't have to think about applying any stashes by themselves. 220 | 221 | # 1.8.0 222 | - `mob next` does not show the same committer multiple times in the list of previous committers. 223 | - `mob next` does not suggest the current Git user to be the next typist as long as there were other persons involved in the mob. 224 | - `mob next` performs a simple lookahead to also suggest persons who might have been absent only during the last mob round. 225 | - When user.name is not set in the git config, mob no longer shows an error but a warning with a help how to fix it. 226 | 227 | # 1.7.0 228 | - Allows creating parallel mob sessions on the same repository more easily. 229 | - `mob branch` shows all remote mob branches currently available. 230 | - `mob fetch` fetches the remote state, so you have everything up to date locally. Helpful to combine with `mob status` and `mob branch` who don't fetch by themselves. 231 | 232 | # 1.6.0 233 | - When `mob start` fails, the timer no longer starts to run. 234 | 235 | # 1.5.0 236 | - Less noisy output: Only show number of unpushed commits in output if there are more than 0. 237 | - Add experimental command `mob squash-wip` to squash any WIP commits in the wip branch into a following manual commit using `git rebase --interactive` with `mob` as the temporary `GIT_EDITOR`. 238 | - The order of the latest commit is now reversed, the latest one is shown last. 239 | - Add experimental configuration option `MOB_WIP_BRANCH_PREFIX` to configure the `mob/` prefix to some other value. 240 | 241 | # 1.4.0 242 | - The list of commits included in a mob branch is now truncated to a maximum of 5 entries to prevent the need for scrolling up in order to see the latest included changes. 243 | - Show more informative error message when `mob ` is run outside of a git repository. 244 | - Add environment variable MOB_TIMER which allows setting a default timer duration for `mob start` and `mob timer` commands. 245 | - Add automatic co-author attribution. Mob will collect all committers from a WIP branch and add them as co-authors in the final WIP commit. 246 | - added support for preventing `mob start` if there are unpushed commits 247 | - better output if one passes a negative number for the timer 248 | 249 | # 1.3.0 250 | - The default `MOB_COMMIT_MESSAGE` now includes `[ci skip]` and `[skip ci]` so that all the typical CI systems (including Azure DevOps) will skip this commit. 251 | - Add `--squash` option to `mob done` that corresponds to `--no-squash`. 252 | - Fixes the default text to speech command on linux and osx. 253 | - Removed `MOB_DEBUG` environment variable (has been deprecated for some time). 254 | 255 | # 1.2.0 256 | - Add environment variable `MOB_REQUIRE_COMMIT_MESSAGE` which you could set to true to require a commit message other than the default one. 257 | - Fixes a bug where you could not run `mob start --branch feature-1` because feature-1 contained a dash. 258 | - Fixes a bug which prevented the sound output of 'mob next' and 'moo' on windows 259 | 260 | # 1.1.0 261 | - Add optional `--no-squash` for `mob done` to keep commits from wip branch. 262 | - Add environment variable `MOB_DONE_SQUASH` to configure the `mob done` behaviour. `MOB_DONE_SQUASH=false` is equal to passing flag `--no-squash`. 263 | - Special thanks to @jbrains, @koeberlue, @gregor_riegler for making this release happen, obviously, in a remote mob session. 264 | 265 | # 1.0.0 266 | - BREAKING: `MOB_WIP_BRANCH_QUALIFIER_SEPARATOR` now defaults to '-'. 267 | - BREAKING: `MOB_NEXT_STAY` now defaults to 'true'. 268 | - Proposed cli commands like `mob start --include-uncommitted-changes` are now shown on a separate line to allow double clicking to copy in the terminal. 269 | 270 | # 0.0.27 271 | - Add way to configure `MOB_WIP_BRANCH_QUALIFIER` via an environment variable instead of `--branch` parameter. Helpful if multiple teams work on the same repository. 272 | - Add way to configure `MOB_WIP_BRANCH_QUALIFIER_SEPARATOR` via an environment variable. Defaults to '/'. Will change to '-' in future versions to prevent branch naming conflicts (one cannot have a branch named `mob/main` and a branch named `mob/main/green` because `mob/main` cannot be a file and a directory at the same time). 273 | 274 | # 0.0.26 275 | - Adds way to configure the voice command via the environment variable `MOB_VOICE_COMMAND`. 276 | - Allow disabling voice or notification by setting the environment variables `MOB_VOICE_COMMAND` or `MOB_NOTIFY_COMMAND` to an empty string. 277 | - Fixes a bug where a failure in executing the voice command would lead to omitting the notification. 278 | - `mob config` now shows the currently used `MOB_VOICE_COMMAND` and `MOB_NOTIFY_COMMAND`. 279 | - Add `mob next --message "custom commit message"` as an option to override the commit message during `mob next`. 280 | 281 | # 0.0.25 282 | - Adds flag `--return-to-base-branch` (with shorthand `-r`) to return to base branch on `mob next`. Because 'mob' will change the default behavior from returning to the base branch to staying on the wip branch on `mob next`, this flag provides the inverse operation of `--stay`. If both are provided, the latter one wins. 283 | - Adds flag `-i` as a shorthand notation for `--include-uncommitted-changes`. 284 | - Fixes a bug that prevented `mob start` to work when on an outdated the WIP branch 285 | - `mob next` push if there are commits but no changes. 286 | 287 | # 0.0.24 288 | - Fixes a bug where mob couldn't handle branch names with the '/' character 289 | 290 | # 0.0.23 291 | - Commit message of wip commits is no longer quoted (see #52) 292 | 293 | # 0.0.22 294 | - Adds `mob start --branch ` to allow multiple wip branches in the form of 'mob//' for a base branch. For example, when being on branch 'main' a `mob start --branch green` would switch to a wip branch named 'mob/main/green'. 295 | - Adds `mob moo` (Thanks Niko for the idea) 296 | - Deprecated `MOB_DEBUG` in favor of the parameter `--debug` 297 | - Deprecated `MOB_START_INCLUDE_UNCOMMITTED_CHANGES` in favor of the parameter `--include-uncommitted-changes` instead 298 | - Show warning if removed configuration option `MOB_BASE_BRANCH` or `MOB_WIP_BRANCH` is used. 299 | 300 | # 0.0.20 301 | - `mob start` on a branch named `feature1` will switch to the branch `mob/feature1` and will merge the changes back to `feature1` after `mob done`. For the `master` branch, the `mob-session` branch will still work (but this may change in the future, switching to `mob/master` at some point). 302 | - Removes configuration options for base branch and wip branch. These are no longer necessary. 303 | - `mob status` added. Thanks to Jeff Langr for that contribution! 304 | 305 | # 0.0.19 306 | - Removes zoom screen share integration. 307 | - Less git commands necessary for 'mob start' 308 | - Mob automatically provides sound output on windows without any installation 309 | 310 | # 0.0.18 311 | - Fixes a bug where boolean environment variables such as `MOB_NEXT_STAY` set to any value (including empty value) falsely activated their respective option. 312 | - Simplified `mob start` when joining a mob session. It uses `git checkout -B mob-session origin/mob-session` to override any local `mob-session` in the process. It reduces the amount of commands necessary and makes the mob tool more predictable: the `origin/mob-session` always contains the truth. 313 | - Removes `mob share` command. You can still enable the zoom integration via `mob start 10 share` although this is now DEPRECATED and will eventually be removed in the future. 314 | 315 | # 0.0.16 316 | - `mob start` prints out untracked files as well 317 | - `mob start --include-uncommitted-changes` now includes untracked files in the stash 'n' pop as well 318 | - keying in an unknown command like `mob conf` will internally call `mob help` to print out the usage options instead of calling `mob status` 319 | - fixed a bug where overriding `MOB_START_INCLUDE_UNCOMMITTED_CHANGES` via an environment variable could print out a wrong value (didn't affect any logic, just wrong console output) 320 | 321 | # 0.0.15 322 | - Any `git push` command now uses the `--no-verify` flag 323 | 324 | # 0.0.14 325 | - New homepage available at https://mob.sh 326 | - `mob config` prints configuration using the environment variable names which allow overriding the values 327 | 328 | # 0.0.13 329 | - Fixes bug that prevented users wih git versions below 2.21 to be able to use 'mob'. 330 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | mob.sh -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are very welcome! Below are some helpful guidelines. 4 | 5 | ## How to build the project 6 | 7 | **mob** requires at least [Go](https://go.dev/) 1.15 to build: 8 | 9 | ``` 10 | $ cd /path/to/git/clone/of/remotemobprogramming/mob 11 | $ go build 12 | ``` 13 | 14 | Running single test files during development is probably easiest in your IDE. 15 | To check if all tests are passing, simply run 16 | 17 | ``` 18 | $ go test ./... -v 19 | ``` 20 | 21 | To do some manual testing, you can run it in this repository or install the new binary to `/usr/local/bin/`: 22 | 23 | ``` 24 | $ go run . 25 | OR 26 | $ ./install 27 | ``` 28 | 29 | Afterwards, you can check if everything works as you expect. 30 | If it does not, you might want to add the `--debug` option to your call: 31 | 32 | ``` 33 | $ mob config --debug 34 | ``` 35 | 36 | 37 | ## How to contribute 38 | 39 | If you want to tackle an existing issue please add a comment on GitHub to make sure the issue is 40 | sufficiently discussed and that no two contributors collide by working on the same issue. 41 | To submit a contribution, please follow the following workflow: 42 | 43 | - Fork the project 44 | - Create a feature branch 45 | - Add your contribution 46 | - Test your changes locally, i.e. do an `./install` and try your new version of `mob` 47 | - Run all the tests via `go test -v`, and if they pass: 48 | - Create a Pull Request 49 | 50 | That's it! Happy contributing 😃 51 | 52 | 53 | ## Going back to the official release 54 | 55 | When you've finished local testing (and you've created a pull request), maybe you want to go back to the 56 | official `mob` releases. If you're using a package manager, you probably have to delete your locally 57 | built binary first ... 58 | 59 | ``` 60 | rm /usr/local/bin/mob 61 | ``` 62 | 63 | ... and then reactivate the `mob` version provided by your package manager. If you're using Homebrew, 64 | it works like this: 65 | 66 | ``` 67 | brew unlink mob && brew link mob 68 | ``` 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dr. Simon Harrer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fast git handover with mob 2 | 3 | ![mob Logo](logo_def.svg) 4 |

5 | 6 | Test Workflow 7 | 8 | Contributors 9 | 10 | Downloads 11 | 12 | Downloads of latest 13 | 14 | Stars 15 | Slack Status 16 |

17 | 18 | **#StandWithUkraine [donate here](https://www.savethechildren.org.uk/where-we-work/europe/ukraine) #StandWithUkraine** 19 | 20 | Fast [git handover](https://www.remotemobprogramming.org/#git-handover) for remote pair/mob programming. 21 | 22 | - **mob** is [an open source command line tool written in go](https://github.com/remotemobprogramming/mob) 23 | - **mob** is the fastest way to [hand over code via git](https://www.remotemobprogramming.org/#git-handover) 24 | - **mob** keeps your branches clean and only creates WIP commits on temporary branches 25 | - **mob** has a shared team timer [timer.mob.sh](https://timer.mob.sh) 26 | - **mob** is on 'assess' in the [Thoughtworks Technology Radar](https://twitter.com/simonharrer/status/1453372354097205253?s=20) 27 | - **mob** has [VSCode integration](https://marketplace.visualstudio.com/items?itemName=alessandrosangalli.mob-vscode-gui) 28 | 29 | ![diagram how mob works](diagram_def.svg) 30 | 31 | ## What people say about mob 32 | 33 | > Sometimes you come across a tool that you didn't realize you needed until you do; mob is just such a tool. Living as we do in a world where remote pair programming has become the norm for many teams, having a tool that allows for seamless handover either between pairs or a wider group as part of a mob programming session is super useful. mob hides all the version control paraphernalia behind a command-line interface that makes participating in mob programming sessions simpler. It also provides specific advice around how to participate remotely, for example, to "steal the screenshare" in Zoom rather than ending a screenshare, ensuring the video layout doesn't change for participants. A useful tool and thoughtful advice, what's not to like? — [Technology Radar 34 | Volume 25, thoughtworks](https://www.thoughtworks.com/radar/tools/mob) 35 | 36 | > "Mob has allowed us to run fast-paced, engaging, and effective sessions by enabling sub-10-second handover times and otherwise getting out of the way. A simple but great tool!" — [Jeff Langr, developer](https://twitter.com/jlangr) 37 | 38 | > "I love it, it is a quantum leap in our collaboration." — Vasiliy Sivovolov, Senior Software Engineer 39 | 40 | >"What a great tool to organise remote working." — [Jennifer Gommans, IT Consultant](https://twitter.com/missjennbo) 41 | 42 | > "I was recently introduced to [mob.sh](https://mob.sh) for remote pairing/mobbing collaboration and I absolutely love it. The timer feature is really a selling point for me. Kudos" — [Fabien Illert, IT Consultant](https://twitter.com/fabienillert) 43 | 44 | ## How to install 45 | 46 | The recommended way to install mob is as a binary via the provided install script: 47 | 48 | ```bash 49 | # Works for macOS, Linux, and even on Windows in Git Bash 50 | curl -sL install.mob.sh | sh 51 | ``` 52 | 53 | On macOS via Homebrew: 54 | 55 | ```bash 56 | brew install mob 57 | ``` 58 | 59 | On Windows via [Scoop](https://scoop.sh/): 60 | 61 | ```bash 62 | scoop install mob 63 | ``` 64 | 65 | or via [Chocolatey](https://chocolatey.org/): 66 | 67 | ```bash 68 | choco install mob 69 | ``` 70 | 71 | On Arch Linux via yay: 72 | 73 | ```bash 74 | yay -S mobsh-bin 75 | ``` 76 | 77 | On [Nix](http://nixos.org) through declarative installation: 78 | 79 | ```nix 80 | { pkgs, ... }: 81 | { 82 | # Either for all users 83 | environment.systemPackages = with pkgs; [ mob ]; 84 | 85 | # Or for an explicit user 86 | users.users."youruser".packages = with pkgs; [ mob ]; 87 | } 88 | ``` 89 | 90 | On NetBSD, macOS, SmartOS, Linux, FreeBSD, OpenBSD, and more, via [pkgsrc](https://pkgsrc.org): 91 | 92 | ```bash 93 | # If there's a binary package for your platform 94 | pkgin install mob 95 | 96 | # Otherwise, for any platform 97 | cd pkgsrc/devel/mob && bmake install clean 98 | ``` 99 | 100 | On Ubuntu there's an EXPERIMENTAL [snap](https://snapcraft.io/mob-sh) package with a known limitation (ssh-agent not working): 101 | 102 | ```bash 103 | sudo snap install mob-sh 104 | sudo snap connect mob-sh:ssh-keys 105 | ``` 106 | 107 | 108 | ### Using go tools 109 | 110 | If you have go 1.20+ you can install and build directly from source: 111 | 112 | ```bash 113 | go install github.com/remotemobprogramming/mob/v4@latest 114 | ``` 115 | 116 | or pick a specific version: 117 | 118 | ```bash 119 | go install github.com/remotemobprogramming/mob/v4@v4.4.0 120 | ``` 121 | 122 | or to install latest unreleased changes: 123 | 124 | ```bash 125 | go install github.com/remotemobprogramming/mob/v4@main 126 | ``` 127 | 128 | ## How to use 129 | 130 | You only need three commands: `mob start`, `mob next`, and `mob done`. 131 | 132 | Switch to a separate branch with `mob start` and handover to the next person with `mob next`. 133 | Repeat. 134 | When you're done, get your changes into the staging area of the `main` branch with `mob done` and commit them. 135 | 136 | [![asciicast](https://asciinema.org/a/321885.svg)](https://asciinema.org/a/321885) 137 | 138 | Here's a short example on how the two developers Carola and Maria code a feature together and push it in the end. 139 | 140 | ```bash 141 | # Carola 142 | main $ mob start 143 | mob/main $ echo "hello" > work.txt 144 | mob/main $ mob next 145 | 146 | # Maria 147 | main $ mob start 148 | mob/main $ cat work.txt # shows "hello" 149 | mob/main $ echo " world" >> work.txt 150 | mob/main $ mob next 151 | 152 | # Carola 153 | mob/main $ mob start 154 | mob/main $ cat work.txt # shows "hello world" 155 | mob/main $ echo "!" >> work.txt 156 | mob/main $ mob done 157 | main $ git commit -m "create greeting file" 158 | main $ git push 159 | ``` 160 | 161 | And here's the man page of the tool: 162 | 163 | ``` 164 | mob enables a smooth Git handover 165 | 166 | Basic Commands: 167 | start start session from base branch in wip branch 168 | next handover changes in wip branch to next person 169 | done squashes all changes in wip branch to index in base branch 170 | reset removes local and remote wip branch 171 | clean removes all orphan wip branches 172 | 173 | Basic Commands(Options): 174 | start [] Start a timer 175 | [--include-uncommitted-changes|-i] Move uncommitted changes to wip branch 176 | [--discard-uncommitted-changes|-d] Discard uncommitted changes 177 | [--branch|-b ] Set wip branch to 'mob/-' 178 | [--create|-c] Create the remote branch 179 | [--room ] Set room name for timer.mob.sh once 180 | next 181 | [--stay|-s] Stay on wip branch (default) 182 | [--return-to-base-branch|-r] Return to base branch 183 | [--message|-m ] Override commit message 184 | done 185 | [--no-squash] Squash no commits from wip branch, only merge wip branch 186 | [--squash] Squash all commits from wip branch 187 | [--squash-wip] Squash wip commits from wip branch, maintaining manual commits 188 | reset 189 | [--branch|-b ] Set wip branch to 'mob//' 190 | goal Gives you the current goal of your timer.mob.sh room 191 | [] Sets the goal of your timer.mob.sh room 192 | [--delete] Deletes the goal of your timer.mob.sh room 193 | 194 | 195 | Timer Commands: 196 | timer Start a timer 197 | [--room ] Set room name for timer.mob.sh once 198 | timer open Opens the timer website 199 | [--room ] Set room name for timer.mob.sh once 200 | start Start mob session in wip branch and a timer 201 | break Start a break timer 202 | goal Gives you the current goal of your timer.mob.sh room 203 | [] Sets the goal of your timer.mob.sh room 204 | [--delete] Deletes the goal of your timer.mob.sh room 205 | 206 | Short Commands (Options and descriptions as above): 207 | s alias for 'start' 208 | n alias for 'next' 209 | d alias for 'done' 210 | b alias for 'branch' 211 | t alias for 'timer' 212 | g Alias for 'goal' 213 | 214 | Get more information: 215 | status show the status of the current session 216 | fetch fetch remote state 217 | branch show remote wip branches 218 | config show all configuration options 219 | version show the version 220 | help show help 221 | 222 | Other 223 | moo moo! 224 | 225 | Add --debug to any option to enable verbose logging 226 | 227 | 228 | Examples: 229 | # start 10 min session in wip branch 'mob-session' 230 | mob start 10 231 | 232 | # start session in wip branch 'mob//green' 233 | mob start --branch green 234 | 235 | # handover code and return to base branch 236 | mob next --return-to-base-branch 237 | 238 | # squashes all commits and puts changes in index of base branch 239 | mob done 240 | 241 | # make a sound check 242 | mob moo 243 | ``` 244 | 245 | If you need some assistance when typing the subcommands and options, you might want to have a look at [fig](https://fig.io/) which gives you autocompletion in your shell. 246 | 247 | ## Best Practices 248 | 249 | - **Say out loud** 250 | - Whenever you key in `mob next` at the end of your turn or `mob start` at the beginning of your turn say the command out loud. 251 | - *Why?* Everybody sees and also hears whose turn is ending and whose turn has started. But even more important, the person whose turn is about to start needs to know when the previous person entered `mob next` so they get the latest commit via their `mob start`. 252 | - **Steal the screenshare** 253 | - After your turn, don't disable the screenshare. Let the next person steal the screenshare. (Requires a setting in Zoom) 254 | - *Why?* This provides more calm (and less diversion) for the rest of the mob as the video conference layout doesn't change, allowing the rest of the mob to keep discussing the problem and finding the best solution, even during a Git handover. 255 | - **Share audio** 256 | - Share your audio when you share your screen. 257 | - *Why?* Sharing audio means everybody will hear when the timer is up. So everybody will help you to rotate, even if you have missed it coincidentally or deliberately. 258 | - **Use a timer** 259 | - Always specify a timer when using `mob start` (for a 5 minute timer use `mob start 5`) 260 | - *Why?* Rotation is key to good pair and mob programming. Just build the habit right from the start. Try to set a timer so everybody can have a turn at least once every 30 minutes. 261 | - **Set up a global shortcut for screensharing** 262 | - Set up a global keyboard shortcut to start sharing your screen. In Zoom, you can do this via Zoom > Preferences > Keyboard Shortcuts. [More tips on setting up Zoom for effective screen sharing.](https://effectivehomeoffice.com/setup-zoom-for-effective-screen-sharing/) 263 | - *Why?* This is just much faster than using the mouse. 264 | - **Set your editor to autosave** 265 | - Have your editor save your files on every keystroke automatically. IntelliJ products do this automatically. VS Code, however, needs to be configured via "File > Auto Save toggle". 266 | - *Why?* Sometimes people forget to save their files. With autosave, any change will be handed over via `mob next`. 267 | 268 | ### The Perfect Git Handover 269 | 270 | The perfect git handover is quick, requires no talking, and allows the rest of the team to continue discussing how to best solve the current problem undisturbed by the handover. Here's how to achieve that. 271 | 272 | - **Situation** Maria is typist sharing the screen, Mona is next 273 | - **Maria** runs `mob next` 274 | - keeps sharing the screen with the terminal showing the successful run of `mob next` 275 | - does nothing (i.e., no typing, no mouse cursor movement, no window switching) 276 | - **Mona** steals screenshare using keyboard shortcut, and, afterwards, runs `mob start` 277 | - **Maria** checks her twitter 278 | 279 | ### Complementary Scripts 280 | 281 | `mob-start feature1` creates a new base branch `feature1` to immediately start a wip branch `mob/feature1` from there. 282 | 283 | ```bash 284 | mob-start() { git checkout -b "$@" && git push origin "$@" --set-upstream && mob start --include-uncommitted-changes; } 285 | ``` 286 | 287 | ### Useful Aliases 288 | 289 | ```bash 290 | alias ms='mob start' 291 | alias mn='mob next' 292 | alias md='mob done' 293 | alias moo='mob moo' 294 | ``` 295 | 296 | ### Use the name you like 297 | 298 | ```bash 299 | mob version 300 | #v1.11.0 301 | alias ensemble='mob' # introduce alias 302 | export MOB_CLI_NAME='ensemble' # makes it aware of the alias 303 | ensemble next 304 | #👉 to start working together, use 305 | # 306 | # ensemble start 307 | # 308 | ``` 309 | 310 | And starting with v1.12.0, `mob` is symlink aware as well: 311 | 312 | ```bash 313 | mob version 314 | #v1.12.0 315 | ln -s /usr/local/bin/mob /usr/local/bin/ensemble 316 | ensemble next 317 | #👉 to start working together, use 318 | # 319 | # ensemble start 320 | # 321 | ``` 322 | 323 | ### Automatically set the timer room when using ticket numbers as branch modifiers 324 | 325 | Say you're a larger team and work on the same git repository using ticket numbers as branch modifiers. 326 | It's easy to forget exporting the room that enables the integration with timer.mob.sh. 327 | Just set the configuration option `MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=true` in `~/.mob` for that. 328 | 329 | ### Automatically open the last modified file of the previous typist 330 | 331 | When you are rotating the typist, you often need to open the file, which the previous typist has modified last. 332 | Mob supports you and can automate this step. You just need the configuration option `MOB_OPEN_COMMAND` with the command to open a file in your preferred IDE. 333 | 334 | For example if you want use IntelliJ the configuration option would look like this: `MOB_OPEN_COMMAND="idea %s"` 335 | 336 | ## More on Installation 337 | 338 | ### Known Issues 339 | 340 | - When you have an ssh key with a password and you running mob on windows in powershell, you will not be able to enter a password for your ssh key. You can circumvent this problem by using the git bash instead of powershell. 341 | 342 | ### Linux Timer 343 | 344 | (This is not needed when installing via snap.) 345 | 346 | To get the timer to play "mob next" on your speakers when your time is up, you'll need an installed speech engine. 347 | Install that on Debian/Ubuntu/Mint as follows: 348 | 349 | ```bash 350 | sudo apt-get install espeak-ng-espeak mbrola-us1 351 | ``` 352 | 353 | or on Arch Linux as follows: 354 | ```bash 355 | sudo pacman -S espeak-ng-espeak 356 | yay -S mbrola-voices-us1 357 | ``` 358 | 359 | Create a little script in your `$PATH` called `say` with the following content: 360 | 361 | ```bash 362 | #!/bin/sh 363 | espeak -v us-mbrola-1 "$@" 364 | ``` 365 | 366 | If you use WSL2 on windows, install eSpeak as windows tool and Create a little script in your `$PATH` called `say` with the following content: 367 | 368 | ```bash 369 | #!/bin/sh 370 | /mnt/c/Program\ Files\ \(x86\)/eSpeak/command_line/espeak.exe "$@" 371 | ``` 372 | 373 | make sure that the path to the windows `espeak.exe`fits your installation. 374 | You can avoid the long path by adding it to your windows path variable. 375 | 376 | ## How to configure 377 | 378 | Show your current configuration with `mob config`: 379 | 380 | ```toml 381 | MOB_CLI_NAME="mob" 382 | MOB_DONE_SQUASH=squash 383 | MOB_GIT_HOOKS_ENABLED=false 384 | MOB_NEXT_STAY=true 385 | MOB_NOTIFY_COMMAND="/usr/bin/osascript -e 'display notification \"%s\"'" 386 | MOB_NOTIFY_MESSAGE="mob next" 387 | MOB_OPEN_COMMAND="idea %s" 388 | MOB_REMOTE_NAME="origin" 389 | MOB_REQUIRE_COMMIT_MESSAGE=false 390 | MOB_SKIP_CI_PUSH_OPTION_ENABLED=true 391 | MOB_START_COMMIT_MESSAGE="mob start [ci-skip] [ci skip] [skip ci]" 392 | MOB_START_CREATE=false 393 | MOB_STASH_NAME="mob-stash-name" 394 | MOB_TIMER_LOCAL=true 395 | MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=false 396 | MOB_TIMER_ROOM="mob" 397 | MOB_TIMER_URL="https://timer.mob.sh/" 398 | MOB_TIMER_USER="sh" 399 | MOB_TIMER="" 400 | MOB_VOICE_COMMAND="say \"%s\"" 401 | MOB_VOICE_MESSAGE="mob next" 402 | MOB_WIP_BRANCH_PREFIX="mob/" 403 | MOB_WIP_BRANCH_QUALIFIER_SEPARATOR="-" 404 | MOB_WIP_BRANCH_QUALIFIER="" 405 | MOB_WIP_COMMIT_MESSAGE="mob next [ci-skip] [ci skip] [skip ci]" 406 | ``` 407 | 408 | Override default value permanently via a `.mob` file in your user home or in your git project repository root. (recommended) 409 | 410 | Override default value permanently via environment variables: 411 | 412 | ```bash 413 | export MOB_NEXT_STAY=true 414 | ``` 415 | 416 | Override default value just for a single call: 417 | 418 | ```bash 419 | MOB_NEXT_STAY=true mob next 420 | ``` 421 | 422 | ### Integration with timer.mob.sh 423 | For your name to show up in the room at timer.mob.sh you must set a timer value either via the `MOB_TIMER` variable, a config file, or an argument to `start`. 424 | 425 | ## How to uninstall 426 | Mob can simply be uninstalled by removing the installed binary (at least if it was installed via the http://install.mob.sh script). 427 | 428 | ### Linux 429 | 430 | ```bash 431 | rm /usr/local/bin/mob 432 | ``` 433 | 434 | ### Windows (Git Bash) 435 | 436 | ```bash 437 | rm ~/bin/mob.exe 438 | ``` 439 | 440 | ### MacOS 441 | 442 | ```bash 443 | brew uninstall remotemobprogramming/brew/mob 444 | ``` 445 | 446 | ## How to contribute 447 | 448 | [Propose your change in an issue](https://github.com/remotemobprogramming/mob/issues) or [directly create a pull request with your improvements](https://github.com/remotemobprogramming/mob/pulls). 449 | 450 | ```bash 451 | # PROJECT_ROOT is the root of the project/repository 452 | 453 | cd $PROJECT_ROOT 454 | 455 | git version # >= 2.17 456 | go version # >= 1.15 457 | 458 | go build # builds 'mob' 459 | 460 | go test # runs all tests 461 | go test -run TestDetermineBranches # runs the single test named 'TestDetermineBranches' 462 | 463 | # run tests and show test coverage in browser 464 | go test -coverprofile=cover.out && go tool cover -html=cover.out 465 | ``` 466 | 467 | ## Design Concepts 468 | 469 | - **mob** is a thin wrapper around git. 470 | - **mob** is not interactive. 471 | - **mob** owns its wip branches. It will create wip branches, make commits, push them, but also delete them. 472 | - **mob** requires the user to do changes in non-wip branches. 473 | - **mob** provides a copy'n'paste solution if it encounters an error. 474 | - **mob** relies on information accessible via git. 475 | - **mob** provides only a few environment variables for configuration. 476 | - **mob** only uses the Go standard library and no 3rd party plugins. 477 | 478 | ## Who is using 'mob'? 479 | 480 | - [INNOQ](https://www.innoq.com) 481 | - [BLUME2000](https://twitter.com/slashBene/status/1337329356637687811?s=20) 482 | - [REWE Digital](https://www.rewe-digital.com/) 483 | - [Amadeus IT Group](https://amadeus.com/) 484 | - [DB Systel GmbH](https://www.dbsystel.de/) 485 | - [FlixMobility TECH (FlixBus)](https://www.flixbus.com/) 486 | - And probably many others who shall not be named. 487 | 488 | ## Credits 489 | 490 | Created by [Dr. Simon Harrer](https://twitter.com/simonharrer) in September 2018. 491 | 492 | Currently maintained by [Gregor Riegler](https://twitter.com/gregor_riegler) and [Joshua Töpfer](https://twitter.com/JoshuaToepfer), and to some limited degree still by [Dr. Simon Harrer](https://twitter.com/simonharrer). 493 | 494 | Contributions and testing by Jochen Christ, Martin Huber, Franziska Dessart, Nikolas Hermann 495 | and Christoph Welcz. Thank you! 496 | 497 | Logo designed by [Sonja Scheungrab](https://twitter.com/multebaerr). 498 | 499 | 500 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - jekyll-sitemap 3 | markdown: kramdown 4 | title: "Fast git handover with mob" 5 | -------------------------------------------------------------------------------- /_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% seo %} 18 | 19 | 54 | 55 | 56 | 57 |
58 | {% if site.title and site.title != page.title %} 59 |

{{ site.title }}

60 | {% endif %} 61 | 62 | {{ content }} 63 | 64 | {% if site.github.private != true and site.github.license %} 65 | 68 | {% endif %} 69 |
70 | 71 | 72 | {% if site.google_analytics %} 73 | 81 | {% endif %} 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /coauthors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/remotemobprogramming/mob/v5/say" 7 | "os" 8 | "path" 9 | "regexp" 10 | "sort" 11 | "strings" 12 | ) 13 | 14 | // Author is a coauthor "Full Name " 15 | type Author = string 16 | 17 | func collectCoauthorsFromWipCommits(file *os.File) []Author { 18 | // Here we parse the SQUASH_MSG file for the list of authors on 19 | // the WIP branch. If this technique later turns out to be 20 | // problematic, an alternative would be to instead fetch the 21 | // authors' list from the git log, using e.g.: 22 | // 23 | // silentgit("log", fmt.Sprintf("%s..", currentBaseBranch), "--reverse", "--pretty=format:%an <%ae>") 24 | // 25 | // For details and background, see https://github.com/remotemobprogramming/mob/issues/81 26 | 27 | coauthors := parseCoauthors(file) 28 | say.Debug("Parsed coauthors") 29 | say.Debug(strings.Join(coauthors, ",")) 30 | 31 | coauthors = removeElementsContaining(coauthors, gitUserEmail()) 32 | say.Debug("Parsed coauthors without committer") 33 | say.Debug(strings.Join(coauthors, ",")) 34 | 35 | coauthors = removeDuplicateValues(coauthors) 36 | say.Debug("Unique coauthors without committer") 37 | say.Debug(strings.Join(coauthors, ",")) 38 | 39 | sortByLength(coauthors) 40 | say.Debug("Sorted unique coauthors without committer") 41 | say.Debug(strings.Join(coauthors, ",")) 42 | 43 | return coauthors 44 | } 45 | 46 | func parseCoauthors(file *os.File) []Author { 47 | var coauthors []Author 48 | 49 | authorOrCoauthorMatcher := regexp.MustCompile("(?i).*(author)+.+<+.*>+") 50 | scanner := bufio.NewScanner(file) 51 | 52 | for scanner.Scan() { 53 | line := scanner.Text() 54 | if authorOrCoauthorMatcher.MatchString(line) { 55 | author := stripToAuthor(line) 56 | coauthors = append(coauthors, author) 57 | } 58 | } 59 | return coauthors 60 | } 61 | 62 | func stripToAuthor(line string) Author { 63 | return strings.TrimSpace(strings.Join(strings.Split(line, ":")[1:], "")) 64 | } 65 | 66 | func sortByLength(slice []string) { 67 | sort.Slice(slice, func(i, j int) bool { 68 | return len(slice[i]) < len(slice[j]) 69 | }) 70 | } 71 | 72 | func removeElementsContaining(slice []string, containsFilter string) []string { 73 | var result []string 74 | 75 | for _, entry := range slice { 76 | if !strings.Contains(entry, containsFilter) { 77 | result = append(result, entry) 78 | } 79 | } 80 | return result 81 | } 82 | 83 | func removeDuplicateValues(slice []string) []string { 84 | var result []string 85 | 86 | keys := make(map[string]bool) 87 | for _, entry := range slice { 88 | if _, value := keys[entry]; !value { 89 | keys[entry] = true 90 | result = append(result, entry) 91 | } 92 | } 93 | return result 94 | } 95 | 96 | func appendCoauthorsToSquashMsg(gitDir string) error { 97 | squashMsgPath := path.Join(gitDir, "SQUASH_MSG") 98 | say.Debug("opening " + squashMsgPath) 99 | file, err := os.OpenFile(squashMsgPath, os.O_APPEND|os.O_RDWR, 0644) 100 | if err != nil { 101 | if os.IsNotExist(err) { 102 | say.Debug(squashMsgPath + " does not exist") 103 | // No wip commits, nothing to squash, this isn't really an error 104 | return nil 105 | } 106 | return err 107 | } 108 | 109 | defer file.Close() 110 | 111 | // read from repo/.git/SQUASH_MSG 112 | coauthors := collectCoauthorsFromWipCommits(file) 113 | 114 | if len(coauthors) > 0 { 115 | coauthorSuffix := createCommitMessage(coauthors) 116 | 117 | // append to repo/.git/SQUASH_MSG 118 | writer := bufio.NewWriter(file) 119 | writer.WriteString(coauthorSuffix) 120 | err = writer.Flush() 121 | } 122 | 123 | return err 124 | } 125 | 126 | func createCommitMessage(coauthors []Author) string { 127 | commitMessage := "\n\n" 128 | commitMessage += "# automatically added all co-authors from WIP commits\n" 129 | commitMessage += "# add missing co-authors manually\n" 130 | for _, coauthor := range coauthors { 131 | commitMessage += fmt.Sprintf("Co-authored-by: %s\n", coauthor) 132 | } 133 | return commitMessage 134 | } 135 | -------------------------------------------------------------------------------- /coauthors_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestStartDoneCoAuthors(t *testing.T) { 9 | _, configuration := setup(t) 10 | 11 | setWorkingDir(tempDir + "/alice") 12 | start(configuration) 13 | createFile(t, "file3.txt", "contentIrrelevant") 14 | next(configuration) 15 | 16 | setWorkingDir(tempDir + "/local") 17 | start(configuration) 18 | createFile(t, "file1.txt", "contentIrrelevant") 19 | next(configuration) 20 | 21 | setWorkingDir(tempDir + "/localother") 22 | start(configuration) 23 | createFile(t, "file2.txt", "contentIrrelevant") 24 | next(configuration) 25 | 26 | setWorkingDir(tempDir + "/alice") 27 | start(configuration) 28 | createFile(t, "file4.txt", "contentIrrelevant") 29 | next(configuration) 30 | 31 | setWorkingDir(tempDir + "/bob") 32 | start(configuration) 33 | createFile(t, "file5.txt", "contentIrrelevant") 34 | next(configuration) 35 | 36 | setWorkingDir(tempDir + "/local") 37 | start(configuration) 38 | done(configuration) 39 | 40 | output := readFile(t, filepath.Join(tempDir, "local", ".git", "SQUASH_MSG")) 41 | 42 | // don't include the person running `mob done` 43 | assertOutputNotContains(t, &output, "Co-authored-by: local ") 44 | // include everyone else in commit order after removing duplicates 45 | assertOutputContains(t, &output, "\nCo-authored-by: bob \nCo-authored-by: alice \nCo-authored-by: localother \n") 46 | } 47 | 48 | func TestCreateCommitMessage(t *testing.T) { 49 | equals(t, ` 50 | 51 | # automatically added all co-authors from WIP commits 52 | # add missing co-authors manually 53 | Co-authored-by: Alice 54 | Co-authored-by: Bob 55 | `, createCommitMessage([]Author{"Alice ", "Bob "})) 56 | } 57 | 58 | func TestSortByLength(t *testing.T) { 59 | slice := []string{"aa", "b"} 60 | 61 | sortByLength(slice) 62 | 63 | equals(t, []string{"b", "aa"}, slice) 64 | } 65 | 66 | func TestRemoveDuplicateValues(t *testing.T) { 67 | slice := []string{"aa", "b", "c", "b"} 68 | 69 | actual := removeDuplicateValues(slice) 70 | 71 | equals(t, []string{"aa", "b", "c"}, actual) 72 | } 73 | -------------------------------------------------------------------------------- /configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "bufio" 5 | "github.com/remotemobprogramming/mob/v5/say" 6 | "os" 7 | "runtime" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | Squash = "squash" 14 | NoSquash = "no-squash" 15 | SquashWip = "squash-wip" 16 | ) 17 | 18 | const ( 19 | IncludeChanges = "include-changes" 20 | DiscardChanges = "discard-changes" 21 | FailWithError = "fail-with-error" 22 | ) 23 | 24 | type Configuration struct { 25 | CliName string // override with MOB_CLI_NAME 26 | RemoteName string // override with MOB_REMOTE_NAME 27 | WipCommitMessage string // override with MOB_WIP_COMMIT_MESSAGE 28 | StartCommitMessage string // override with MOB_START_COMMIT_MESSAGE 29 | SkipCiPushOptionEnabled bool // override with MOB_SKIP_CI_PUSH_OPTION_ENABLED 30 | GitHooksEnabled bool // override with MOB_GIT_HOOKS_ENABLED 31 | RequireCommitMessage bool // override with MOB_REQUIRE_COMMIT_MESSAGE 32 | VoiceCommand string // override with MOB_VOICE_COMMAND 33 | VoiceMessage string // override with MOB_VOICE_MESSAGE 34 | NotifyCommand string // override with MOB_NOTIFY_COMMAND 35 | NotifyMessage string // override with MOB_NOTIFY_MESSAGE 36 | NextStay bool // override with MOB_NEXT_STAY 37 | HandleUncommittedChanges string 38 | StartCreate bool // override with MOB_START_CREATE variable 39 | StartJoin bool 40 | StashName string // override with MOB_STASH_NAME 41 | WipBranchQualifier string // override with MOB_WIP_BRANCH_QUALIFIER 42 | WipBranchQualifierSeparator string // override with MOB_WIP_BRANCH_QUALIFIER_SEPARATOR 43 | WipBranchPrefix string // override with MOB_WIP_BRANCH_PREFIX 44 | DoneSquash string // override with MOB_DONE_SQUASH 45 | OpenCommand string // override with MOB_OPEN_COMMAND 46 | Timer string // override with MOB_TIMER 47 | TimerRoom string // override with MOB_TIMER_ROOM 48 | TimerLocal bool // override with MOB_TIMER_LOCAL 49 | TimerRoomUseWipBranchQualifier bool // override with MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER 50 | TimerUser string // override with MOB_TIMER_USER 51 | TimerUrl string // override with MOB_TIMER_URL 52 | TimerInsecure bool // override with MOB_TIMER_INSECURE 53 | ResetDeleteRemoteWipBranch bool // override with MOB_RESET_DELETE_REMOTE_WIP_BRANCH 54 | } 55 | 56 | func (c Configuration) Mob(command string) string { 57 | return c.CliName + " " + command 58 | } 59 | 60 | func (c Configuration) WipBranchQualifierSuffix() string { 61 | return c.WipBranchQualifierSeparator + c.WipBranchQualifier 62 | } 63 | 64 | func (c Configuration) CustomWipBranchQualifierConfigured() bool { 65 | return c.WipBranchQualifier != "" 66 | } 67 | 68 | func (c Configuration) HasCustomCommitMessage() bool { 69 | return GetDefaultConfiguration().WipCommitMessage != c.WipCommitMessage 70 | } 71 | 72 | func (c Configuration) IsWipCommitMessage(line string) bool { 73 | return strings.HasPrefix(line, c.WipCommitMessage) 74 | } 75 | 76 | func (c Configuration) IsOpenCommandGiven() bool { 77 | return strings.TrimSpace(c.OpenCommand) != "" 78 | } 79 | 80 | func Config(c Configuration) { 81 | say.Say("MOB_CLI_NAME" + "=" + quote(c.CliName)) 82 | say.Say("MOB_DONE_SQUASH" + "=" + string(c.DoneSquash)) 83 | say.Say("MOB_GIT_HOOKS_ENABLED" + "=" + strconv.FormatBool(c.GitHooksEnabled)) 84 | say.Say("MOB_NEXT_STAY" + "=" + strconv.FormatBool(c.NextStay)) 85 | say.Say("MOB_NOTIFY_COMMAND" + "=" + quote(c.NotifyCommand)) 86 | say.Say("MOB_NOTIFY_MESSAGE" + "=" + quote(c.NotifyMessage)) 87 | say.Say("MOB_OPEN_COMMAND" + "=" + quote(c.OpenCommand)) 88 | say.Say("MOB_REMOTE_NAME" + "=" + quote(c.RemoteName)) 89 | say.Say("MOB_REQUIRE_COMMIT_MESSAGE" + "=" + strconv.FormatBool(c.RequireCommitMessage)) 90 | say.Say("MOB_SKIP_CI_PUSH_OPTION_ENABLED" + "=" + strconv.FormatBool(c.SkipCiPushOptionEnabled)) 91 | say.Say("MOB_START_COMMIT_MESSAGE" + "=" + quote(c.StartCommitMessage)) 92 | say.Say("MOB_STASH_NAME" + "=" + quote(c.StashName)) 93 | say.Say("MOB_TIMER_INSECURE" + "=" + strconv.FormatBool(c.TimerInsecure)) 94 | say.Say("MOB_TIMER_LOCAL" + "=" + strconv.FormatBool(c.TimerLocal)) 95 | say.Say("MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER" + "=" + strconv.FormatBool(c.TimerRoomUseWipBranchQualifier)) 96 | say.Say("MOB_TIMER_ROOM" + "=" + quote(c.TimerRoom)) 97 | say.Say("MOB_TIMER_URL" + "=" + quote(c.TimerUrl)) 98 | say.Say("MOB_TIMER_USER" + "=" + quote(c.TimerUser)) 99 | say.Say("MOB_TIMER" + "=" + quote(c.Timer)) 100 | say.Say("MOB_VOICE_COMMAND" + "=" + quote(c.VoiceCommand)) 101 | say.Say("MOB_VOICE_MESSAGE" + "=" + quote(c.VoiceMessage)) 102 | say.Say("MOB_WIP_BRANCH_PREFIX" + "=" + quote(c.WipBranchPrefix)) 103 | say.Say("MOB_WIP_BRANCH_QUALIFIER_SEPARATOR" + "=" + quote(c.WipBranchQualifierSeparator)) 104 | say.Say("MOB_WIP_BRANCH_QUALIFIER" + "=" + quote(c.WipBranchQualifier)) 105 | say.Say("MOB_WIP_COMMIT_MESSAGE" + "=" + quote(c.WipCommitMessage)) 106 | } 107 | 108 | func ReadConfiguration(gitRootDir string) Configuration { 109 | configuration := GetDefaultConfiguration() 110 | configuration = parseEnvironmentVariables(configuration) 111 | 112 | userHomeDir, _ := os.UserHomeDir() 113 | userConfigurationPath := userHomeDir + "/.mob" 114 | configuration = parseUserConfiguration(configuration, userConfigurationPath) 115 | if gitRootDir != "" { 116 | configuration = parseProjectConfiguration(configuration, gitRootDir+"/.mob") 117 | } 118 | return configuration 119 | } 120 | 121 | func ParseArgs(args []string, configuration Configuration) (command string, parameters []string, newConfiguration Configuration) { 122 | newConfiguration = configuration 123 | 124 | for i := 1; i < len(args); i++ { 125 | arg := args[i] 126 | switch arg { 127 | case "--discard-uncommitted-changes", "-d": 128 | newConfiguration.HandleUncommittedChanges = DiscardChanges 129 | case "--include-uncommitted-changes", "-i": 130 | newConfiguration.HandleUncommittedChanges = IncludeChanges 131 | case "--debug": 132 | // ignore this, already parsed 133 | case "--stay", "-s": 134 | newConfiguration.NextStay = true 135 | case "--return-to-base-branch", "-r": 136 | newConfiguration.NextStay = false 137 | case "--branch", "-b": 138 | if i+1 != len(args) { 139 | newConfiguration.WipBranchQualifier = args[i+1] 140 | } 141 | i++ // skip consumed parameter 142 | case "--message", "-m": 143 | if i+1 != len(args) { 144 | newConfiguration.WipCommitMessage = args[i+1] 145 | } 146 | i++ // skip consumed parameter 147 | case "--squash": 148 | newConfiguration.DoneSquash = Squash 149 | case "--no-squash": 150 | newConfiguration.DoneSquash = NoSquash 151 | case "--squash-wip": 152 | newConfiguration.DoneSquash = SquashWip 153 | case "--create", "-c": 154 | newConfiguration.StartCreate = true 155 | case "--join", "-j": 156 | newConfiguration.StartJoin = true 157 | case "--delete-remote-wip-branch": 158 | newConfiguration.ResetDeleteRemoteWipBranch = true 159 | case "--room": 160 | if i+1 != len(args) { 161 | newConfiguration.TimerRoom = args[i+1] 162 | } 163 | i++ // skip consumed parameter 164 | 165 | default: 166 | if i == 1 { 167 | command = arg 168 | } else { 169 | parameters = append(parameters, arg) 170 | } 171 | } 172 | } 173 | 174 | return 175 | } 176 | 177 | func GetDefaultConfiguration() Configuration { 178 | voiceCommand := "" 179 | notifyCommand := "" 180 | switch runtime.GOOS { 181 | case "darwin": 182 | voiceCommand = "say \"%s\"" 183 | notifyCommand = "/usr/bin/osascript -e 'display notification \"%s\"'" 184 | case "linux": 185 | voiceCommand = "say \"%s\"" 186 | notifyCommand = "notify-send \"%s\"" 187 | case "windows": 188 | voiceCommand = "(New-Object -ComObject SAPI.SPVoice).Speak(\\\"%s\\\")" 189 | 190 | } 191 | return Configuration{ 192 | CliName: "mob", 193 | RemoteName: "origin", 194 | WipCommitMessage: "mob next [ci-skip] [ci skip] [skip ci]", 195 | StartCommitMessage: "mob start [ci-skip] [ci skip] [skip ci]", 196 | SkipCiPushOptionEnabled: true, 197 | GitHooksEnabled: false, 198 | VoiceCommand: voiceCommand, 199 | VoiceMessage: "mob next", 200 | NotifyCommand: notifyCommand, 201 | NotifyMessage: "mob next", 202 | NextStay: true, 203 | RequireCommitMessage: false, 204 | HandleUncommittedChanges: FailWithError, 205 | StartCreate: false, 206 | WipBranchQualifier: "", 207 | WipBranchQualifierSeparator: "-", 208 | DoneSquash: Squash, 209 | OpenCommand: "", 210 | Timer: "", 211 | TimerLocal: true, 212 | TimerRoom: "", 213 | TimerUser: "", 214 | TimerUrl: "https://timer.mob.sh/", 215 | WipBranchPrefix: "mob/", 216 | StashName: "mob-stash-name", 217 | ResetDeleteRemoteWipBranch: false, 218 | } 219 | } 220 | 221 | func parseUserConfiguration(configuration Configuration, path string) Configuration { 222 | file, err := os.Open(path) 223 | 224 | if err != nil { 225 | say.Debug("No user configuration file found. (" + path + ") Error: " + err.Error()) 226 | return configuration 227 | } else { 228 | say.Debug("Found user configuration file at " + path) 229 | } 230 | 231 | fileScanner := bufio.NewScanner(file) 232 | 233 | for fileScanner.Scan() { 234 | line := strings.TrimSpace(fileScanner.Text()) 235 | say.Debug(line) 236 | if !strings.Contains(line, "=") { 237 | say.Debug("Skip line because line contains no =. Line=" + line) 238 | continue 239 | } 240 | key := line[0:strings.Index(line, "=")] 241 | value := strings.TrimPrefix(line, key+"=") 242 | say.Debug("Key is " + key) 243 | say.Debug("Value is " + value) 244 | switch key { 245 | case "MOB_CLI_NAME": 246 | setUnquotedString(&configuration.CliName, key, value) 247 | case "MOB_REMOTE_NAME": 248 | setUnquotedString(&configuration.RemoteName, key, value) 249 | case "MOB_WIP_COMMIT_MESSAGE": 250 | setUnquotedString(&configuration.WipCommitMessage, key, value) 251 | case "MOB_START_COMMIT_MESSAGE": 252 | setUnquotedString(&configuration.StartCommitMessage, key, value) 253 | case "MOB_SKIP_CI_PUSH_OPTION_ENABLED": 254 | setBoolean(&configuration.SkipCiPushOptionEnabled, key, value) 255 | case "MOB_GIT_HOOKS_ENABLED": 256 | setBoolean(&configuration.GitHooksEnabled, key, value) 257 | case "MOB_REQUIRE_COMMIT_MESSAGE": 258 | setBoolean(&configuration.RequireCommitMessage, key, value) 259 | case "MOB_VOICE_COMMAND": 260 | setUnquotedString(&configuration.VoiceCommand, key, value) 261 | case "MOB_VOICE_MESSAGE": 262 | setUnquotedString(&configuration.VoiceMessage, key, value) 263 | case "MOB_NOTIFY_COMMAND": 264 | setUnquotedString(&configuration.NotifyCommand, key, value) 265 | case "MOB_NOTIFY_MESSAGE": 266 | setUnquotedString(&configuration.NotifyMessage, key, value) 267 | case "MOB_NEXT_STAY": 268 | setBoolean(&configuration.NextStay, key, value) 269 | case "MOB_START_CREATE": 270 | setBoolean(&configuration.StartCreate, key, value) 271 | case "MOB_WIP_BRANCH_QUALIFIER": 272 | setUnquotedString(&configuration.WipBranchQualifier, key, value) 273 | case "MOB_WIP_BRANCH_QUALIFIER_SEPARATOR": 274 | setUnquotedString(&configuration.WipBranchQualifierSeparator, key, value) 275 | case "MOB_WIP_BRANCH_PREFIX": 276 | setUnquotedString(&configuration.WipBranchPrefix, key, value) 277 | case "MOB_DONE_SQUASH": 278 | setMobDoneSquash(&configuration, key, value) 279 | case "MOB_OPEN_COMMAND": 280 | setUnquotedString(&configuration.OpenCommand, key, value) 281 | case "MOB_TIMER": 282 | setUnquotedString(&configuration.Timer, key, value) 283 | case "MOB_TIMER_ROOM": 284 | setUnquotedString(&configuration.TimerRoom, key, value) 285 | case "MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER": 286 | setBoolean(&configuration.TimerRoomUseWipBranchQualifier, key, value) 287 | case "MOB_TIMER_LOCAL": 288 | setBoolean(&configuration.TimerLocal, key, value) 289 | case "MOB_TIMER_USER": 290 | setUnquotedString(&configuration.TimerUser, key, value) 291 | case "MOB_TIMER_URL": 292 | setUnquotedString(&configuration.TimerUrl, key, value) 293 | case "MOB_STASH_NAME": 294 | setUnquotedString(&configuration.StashName, key, value) 295 | case "MOB_TIMER_INSECURE": 296 | setBoolean(&configuration.TimerInsecure, key, value) 297 | case "MOB_RESET_DELETE_REMOTE_WIP_BRANCH": 298 | setBoolean(&configuration.ResetDeleteRemoteWipBranch, key, value) 299 | 300 | default: 301 | continue 302 | } 303 | } 304 | 305 | if err := fileScanner.Err(); err != nil { 306 | say.Warning("User configuration file exists, but could not be read. (" + path + ")") 307 | } 308 | 309 | return configuration 310 | } 311 | 312 | func parseProjectConfiguration(configuration Configuration, path string) Configuration { 313 | file, err := os.Open(path) 314 | 315 | if err != nil { 316 | say.Debug("No project configuration file found. (" + path + ") Error: " + err.Error()) 317 | return configuration 318 | } else { 319 | say.Debug("Found project configuration file at " + path) 320 | } 321 | 322 | fileScanner := bufio.NewScanner(file) 323 | 324 | for fileScanner.Scan() { 325 | line := strings.TrimSpace(fileScanner.Text()) 326 | say.Debug(line) 327 | if !strings.Contains(line, "=") { 328 | say.Debug("Skip line because line contains no =. Line=" + line) 329 | continue 330 | } 331 | key := line[0:strings.Index(line, "=")] 332 | value := strings.TrimPrefix(line, key+"=") 333 | say.Debug("Key is " + key) 334 | say.Debug("Value is " + value) 335 | switch key { 336 | case "MOB_VOICE_COMMAND", "MOB_VOICE_MESSAGE", "MOB_NOTIFY_COMMAND", "MOB_NOTIFY_MESSAGE", "MOB_OPEN_COMMAND": 337 | say.Warning("Skipped overwriting key " + key + " from project/.mob file out of security reasons!") 338 | case "MOB_CLI_NAME": 339 | setUnquotedString(&configuration.CliName, key, value) 340 | case "MOB_REMOTE_NAME": 341 | setUnquotedString(&configuration.RemoteName, key, value) 342 | case "MOB_WIP_COMMIT_MESSAGE": 343 | setUnquotedString(&configuration.WipCommitMessage, key, value) 344 | case "MOB_START_COMMIT_MESSAGE": 345 | setUnquotedString(&configuration.StartCommitMessage, key, value) 346 | case "MOB_SKIP_CI_PUSH_OPTION_ENABLED": 347 | setBoolean(&configuration.SkipCiPushOptionEnabled, key, value) 348 | case "MOB_GIT_HOOKS_ENABLED": 349 | setBoolean(&configuration.GitHooksEnabled, key, value) 350 | case "MOB_REQUIRE_COMMIT_MESSAGE": 351 | setBoolean(&configuration.RequireCommitMessage, key, value) 352 | case "MOB_NEXT_STAY": 353 | setBoolean(&configuration.NextStay, key, value) 354 | case "MOB_START_CREATE": 355 | setBoolean(&configuration.StartCreate, key, value) 356 | case "MOB_WIP_BRANCH_QUALIFIER": 357 | setUnquotedString(&configuration.WipBranchQualifier, key, value) 358 | case "MOB_WIP_BRANCH_QUALIFIER_SEPARATOR": 359 | setUnquotedString(&configuration.WipBranchQualifierSeparator, key, value) 360 | case "MOB_WIP_BRANCH_PREFIX": 361 | setUnquotedString(&configuration.WipBranchPrefix, key, value) 362 | case "MOB_DONE_SQUASH": 363 | setMobDoneSquash(&configuration, key, value) 364 | case "MOB_TIMER": 365 | setUnquotedString(&configuration.Timer, key, value) 366 | case "MOB_TIMER_ROOM": 367 | setUnquotedString(&configuration.TimerRoom, key, value) 368 | case "MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER": 369 | setBoolean(&configuration.TimerRoomUseWipBranchQualifier, key, value) 370 | case "MOB_TIMER_LOCAL": 371 | setBoolean(&configuration.TimerLocal, key, value) 372 | case "MOB_TIMER_USER": 373 | setUnquotedString(&configuration.TimerUser, key, value) 374 | case "MOB_TIMER_URL": 375 | setUnquotedString(&configuration.TimerUrl, key, value) 376 | case "MOB_STASH_NAME": 377 | setUnquotedString(&configuration.StashName, key, value) 378 | case "MOB_TIMER_INSECURE": 379 | setBoolean(&configuration.TimerInsecure, key, value) 380 | case "MOB_RESET_DELETE_REMOTE_WIP_BRANCH": 381 | setBoolean(&configuration.ResetDeleteRemoteWipBranch, key, value) 382 | 383 | default: 384 | continue 385 | } 386 | } 387 | 388 | if err := fileScanner.Err(); err != nil { 389 | say.Warning("Project configuration file exists, but could not be read. (" + path + ")") 390 | } 391 | 392 | return configuration 393 | } 394 | 395 | func setUnquotedString(s *string, key string, value string) { 396 | unquotedValue, err := strconv.Unquote(value) 397 | if err != nil { 398 | say.Warning("Could not set key from configuration file because value is not parseable (" + key + "=" + value + ")") 399 | return 400 | } 401 | *s = unquotedValue 402 | say.Debug("Overwriting " + key + " =" + unquotedValue) 403 | } 404 | 405 | func setBoolean(s *bool, key string, value string) { 406 | boolValue, err := strconv.ParseBool(value) 407 | if err != nil { 408 | say.Warning("Could not set key from configuration file because value is not parseable (" + key + "=" + value + ")") 409 | return 410 | } 411 | *s = boolValue 412 | say.Debug("Overwriting " + key + " =" + strconv.FormatBool(boolValue)) 413 | } 414 | 415 | func setMobDoneSquash(configuration *Configuration, key string, value string) { 416 | if strings.HasPrefix(value, "\"") { 417 | unquotedValue, err := strconv.Unquote(value) 418 | if err != nil { 419 | say.Warning("Could not set key from configuration file because value is not parseable (" + key + "=" + value + ")") 420 | return 421 | } 422 | value = unquotedValue 423 | } 424 | configuration.DoneSquash = doneSquash(value) 425 | say.Debug("Overwriting " + key + " =" + configuration.DoneSquash) 426 | } 427 | 428 | func parseEnvironmentVariables(configuration Configuration) Configuration { 429 | setStringFromEnvVariable(&configuration.CliName, "MOB_CLI_NAME") 430 | if configuration.CliName != GetDefaultConfiguration().CliName { 431 | configuration.WipCommitMessage = configuration.CliName + " next [ci-skip] [ci skip] [skip ci]" 432 | configuration.VoiceMessage = configuration.CliName + " next" 433 | configuration.NotifyMessage = configuration.CliName + " next" 434 | } 435 | 436 | removed("MOB_BASE_BRANCH", "Use '"+configuration.Mob("start")+"' on your base branch instead.") 437 | removed("MOB_WIP_BRANCH", "Use '"+configuration.Mob("start --branch ")+"' instead.") 438 | removed("MOB_START_INCLUDE_UNCOMMITTED_CHANGES", "Use the parameter --include-uncommitted-changes instead.") 439 | experimental("MOB_WIP_BRANCH_PREFIX") 440 | deprecated("MOB_START_COMMIT_MESSAGE", "Please check that everybody you work with uses version 5.0.0 or higher. Then this environment variable can be unset, as it will not have an impact anymore.") 441 | 442 | setStringFromEnvVariable(&configuration.RemoteName, "MOB_REMOTE_NAME") 443 | setStringFromEnvVariable(&configuration.WipCommitMessage, "MOB_WIP_COMMIT_MESSAGE") 444 | setStringFromEnvVariable(&configuration.StartCommitMessage, "MOB_START_COMMIT_MESSAGE") 445 | setBoolFromEnvVariable(&configuration.SkipCiPushOptionEnabled, "MOB_SKIP_CI_PUSH_OPTION_ENABLED") 446 | setBoolFromEnvVariable(&configuration.GitHooksEnabled, "MOB_GIT_HOOKS_ENABLED") 447 | setBoolFromEnvVariable(&configuration.RequireCommitMessage, "MOB_REQUIRE_COMMIT_MESSAGE") 448 | setOptionalStringFromEnvVariable(&configuration.VoiceCommand, "MOB_VOICE_COMMAND") 449 | setStringFromEnvVariable(&configuration.VoiceMessage, "MOB_VOICE_MESSAGE") 450 | setOptionalStringFromEnvVariable(&configuration.NotifyCommand, "MOB_NOTIFY_COMMAND") 451 | setStringFromEnvVariable(&configuration.NotifyMessage, "MOB_NOTIFY_MESSAGE") 452 | setStringFromEnvVariable(&configuration.WipBranchQualifierSeparator, "MOB_WIP_BRANCH_QUALIFIER_SEPARATOR") 453 | 454 | setStringFromEnvVariable(&configuration.WipBranchQualifier, "MOB_WIP_BRANCH_QUALIFIER") 455 | setStringFromEnvVariable(&configuration.WipBranchPrefix, "MOB_WIP_BRANCH_PREFIX") 456 | 457 | setBoolFromEnvVariable(&configuration.NextStay, "MOB_NEXT_STAY") 458 | 459 | setBoolFromEnvVariable(&configuration.StartCreate, "MOB_START_CREATE") 460 | 461 | setDoneSquashFromEnvVariable(&configuration, "MOB_DONE_SQUASH") 462 | 463 | setStringFromEnvVariable(&configuration.OpenCommand, "MOB_OPEN_COMMAND") 464 | 465 | setStringFromEnvVariable(&configuration.Timer, "MOB_TIMER") 466 | setStringFromEnvVariable(&configuration.TimerRoom, "MOB_TIMER_ROOM") 467 | setBoolFromEnvVariable(&configuration.TimerRoomUseWipBranchQualifier, "MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER") 468 | setBoolFromEnvVariable(&configuration.TimerLocal, "MOB_TIMER_LOCAL") 469 | setStringFromEnvVariable(&configuration.TimerUser, "MOB_TIMER_USER") 470 | setStringFromEnvVariable(&configuration.TimerUrl, "MOB_TIMER_URL") 471 | setBoolFromEnvVariable(&configuration.TimerInsecure, "MOB_TIMER_INSECURE") 472 | 473 | setBoolFromEnvVariable(&configuration.ResetDeleteRemoteWipBranch, "MOB_RESET_DELETE_REMOTE_WIP_BRANCH") 474 | 475 | return configuration 476 | } 477 | 478 | func setStringFromEnvVariable(s *string, key string) { 479 | value, set := os.LookupEnv(key) 480 | if set && value != "" { 481 | *s = value 482 | say.Debug("overriding " + key + "=" + *s) 483 | } 484 | } 485 | 486 | func setOptionalStringFromEnvVariable(s *string, key string) { 487 | value, set := os.LookupEnv(key) 488 | if set { 489 | *s = value 490 | say.Debug("overriding " + key + "=" + *s) 491 | } 492 | } 493 | 494 | func setBoolFromEnvVariable(s *bool, key string) { 495 | value, set := os.LookupEnv(key) 496 | if !set { 497 | return 498 | } 499 | if value == "" { 500 | say.Debug("ignoring " + key + "=" + value + " (empty string)") 501 | } 502 | 503 | if value == "true" { 504 | *s = true 505 | say.Debug("overriding " + key + "=" + strconv.FormatBool(*s)) 506 | } else if value == "false" { 507 | *s = false 508 | say.Debug("overriding " + key + "=" + strconv.FormatBool(*s)) 509 | } else { 510 | say.Warning("ignoring " + key + "=" + value + " (not a boolean)") 511 | } 512 | } 513 | 514 | func setDoneSquashFromEnvVariable(configuration *Configuration, key string) { 515 | value, set := os.LookupEnv(key) 516 | if !set { 517 | return 518 | } 519 | 520 | configuration.DoneSquash = doneSquash(value) 521 | 522 | if value == "" { 523 | say.Debug("ignoring " + key + "=" + value + " (empty string)") 524 | return 525 | } 526 | 527 | say.Debug("overriding " + key + "=" + configuration.DoneSquash) 528 | } 529 | 530 | func removed(key string, message string) { 531 | if _, set := os.LookupEnv(key); set { 532 | say.Say("Configuration option '" + key + "' is no longer used.") 533 | say.Say(message) 534 | } 535 | } 536 | 537 | func deprecated(key string, message string) { 538 | if _, set := os.LookupEnv(key); set { 539 | say.Say("Configuration option '" + key + "' is deprecated.") 540 | say.Say(message) 541 | } 542 | } 543 | 544 | func experimental(key string) { 545 | if _, set := os.LookupEnv(key); set { 546 | say.Say("Configuration option '" + key + "' is experimental. Be prepared that this option will be removed!") 547 | } 548 | } 549 | 550 | func doneSquash(value string) string { 551 | switch value { 552 | case NoSquash: 553 | return NoSquash 554 | case SquashWip: 555 | return SquashWip 556 | default: 557 | return Squash 558 | } 559 | } 560 | 561 | func quote(value string) string { 562 | return strconv.Quote(value) 563 | } 564 | -------------------------------------------------------------------------------- /configuration/configuration_test.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "github.com/remotemobprogramming/mob/v5/say" 6 | "github.com/remotemobprogramming/mob/v5/test" 7 | "os" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | var ( 13 | tempDir string 14 | ) 15 | 16 | func TestQuote(t *testing.T) { 17 | test.Equals(t, "\"mob\"", quote("mob")) 18 | test.Equals(t, "\"m\\\"ob\"", quote("m\"ob")) 19 | } 20 | 21 | func TestParseArgs(t *testing.T) { 22 | configuration := GetDefaultConfiguration() 23 | test.Equals(t, configuration.WipBranchQualifier, "") 24 | 25 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "--branch", "green"}, configuration) 26 | 27 | test.Equals(t, "start", command) 28 | test.Equals(t, "", strings.Join(parameters, "")) 29 | test.Equals(t, "green", configuration.WipBranchQualifier) 30 | } 31 | 32 | func TestParseArgsStartCreate(t *testing.T) { 33 | configuration := GetDefaultConfiguration() 34 | 35 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "--create"}, configuration) 36 | 37 | test.Equals(t, "start", command) 38 | test.Equals(t, "", strings.Join(parameters, "")) 39 | test.Equals(t, true, configuration.StartCreate) 40 | } 41 | 42 | func TestParseArgsStartCreateShort(t *testing.T) { 43 | configuration := GetDefaultConfiguration() 44 | 45 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "-c"}, configuration) 46 | 47 | test.Equals(t, "start", command) 48 | test.Equals(t, "", strings.Join(parameters, "")) 49 | test.Equals(t, true, configuration.StartCreate) 50 | } 51 | 52 | func TestParseArgsStartJoin(t *testing.T) { 53 | configuration := GetDefaultConfiguration() 54 | 55 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "--join"}, configuration) 56 | 57 | test.Equals(t, "start", command) 58 | test.Equals(t, "", strings.Join(parameters, "")) 59 | test.Equals(t, true, configuration.StartJoin) 60 | } 61 | 62 | func TestParseArgsStartJoinShort(t *testing.T) { 63 | configuration := GetDefaultConfiguration() 64 | 65 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "-j"}, configuration) 66 | 67 | test.Equals(t, "start", command) 68 | test.Equals(t, "", strings.Join(parameters, "")) 69 | test.Equals(t, true, configuration.StartJoin) 70 | } 71 | 72 | func TestParseArgsDoneNoSquash(t *testing.T) { 73 | configuration := GetDefaultConfiguration() 74 | test.Equals(t, Squash, configuration.DoneSquash) 75 | 76 | command, parameters, configuration := ParseArgs([]string{"mob", "done", "--no-squash"}, configuration) 77 | 78 | test.Equals(t, "done", command) 79 | test.Equals(t, "", strings.Join(parameters, "")) 80 | test.Equals(t, NoSquash, configuration.DoneSquash) 81 | } 82 | 83 | func TestParseArgsDoneSquash(t *testing.T) { 84 | configuration := GetDefaultConfiguration() 85 | configuration.DoneSquash = NoSquash 86 | 87 | command, parameters, configuration := ParseArgs([]string{"mob", "done", "--squash"}, configuration) 88 | 89 | test.Equals(t, "done", command) 90 | test.Equals(t, "", strings.Join(parameters, "")) 91 | test.Equals(t, Squash, configuration.DoneSquash) 92 | } 93 | 94 | func TestParseArgsMessage(t *testing.T) { 95 | configuration := GetDefaultConfiguration() 96 | test.Equals(t, configuration.WipBranchQualifier, "") 97 | 98 | command, parameters, configuration := ParseArgs([]string{"mob", "next", "--message", "ci-skip"}, configuration) 99 | 100 | test.Equals(t, "next", command) 101 | test.Equals(t, "", strings.Join(parameters, "")) 102 | test.Equals(t, "ci-skip", configuration.WipCommitMessage) 103 | } 104 | 105 | func TestParseArgsStartRoom(t *testing.T) { 106 | configuration := GetDefaultConfiguration() 107 | test.Equals(t, configuration.WipBranchQualifier, "") 108 | 109 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "--room", "testroom"}, configuration) 110 | 111 | test.Equals(t, "start", command) 112 | test.Equals(t, "", strings.Join(parameters, "")) 113 | test.Equals(t, "testroom", configuration.TimerRoom) 114 | } 115 | 116 | func TestDefaultConfigurationHandleUncommitedChanges(t *testing.T) { 117 | configuration := GetDefaultConfiguration() 118 | 119 | command, parameters, configuration := ParseArgs([]string{"mob", "start"}, configuration) 120 | 121 | test.Equals(t, "start", command) 122 | test.Equals(t, 0, len(parameters)) 123 | test.Equals(t, FailWithError, configuration.HandleUncommittedChanges) 124 | } 125 | 126 | func TestParseArgsIncludeUncommitedChanges(t *testing.T) { 127 | configuration := GetDefaultConfiguration() 128 | 129 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "--include-uncommitted-changes"}, configuration) 130 | 131 | test.Equals(t, "start", command) 132 | test.Equals(t, 0, len(parameters)) 133 | test.Equals(t, IncludeChanges, configuration.HandleUncommittedChanges) 134 | } 135 | 136 | func TestParseArgsIncludeUncommitedChangesShort(t *testing.T) { 137 | configuration := GetDefaultConfiguration() 138 | 139 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "-i"}, configuration) 140 | 141 | test.Equals(t, "start", command) 142 | test.Equals(t, 0, len(parameters)) 143 | test.Equals(t, IncludeChanges, configuration.HandleUncommittedChanges) 144 | } 145 | 146 | func TestParseArgsDiscardUncommitedChanges(t *testing.T) { 147 | configuration := GetDefaultConfiguration() 148 | 149 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "--discard-uncommitted-changes"}, configuration) 150 | 151 | test.Equals(t, "start", command) 152 | test.Equals(t, 0, len(parameters)) 153 | test.Equals(t, DiscardChanges, configuration.HandleUncommittedChanges) 154 | } 155 | 156 | func TestParseArgsDiscardUncommitedChangesShort(t *testing.T) { 157 | configuration := GetDefaultConfiguration() 158 | 159 | command, parameters, configuration := ParseArgs([]string{"mob", "start", "-d"}, configuration) 160 | 161 | test.Equals(t, "start", command) 162 | test.Equals(t, 0, len(parameters)) 163 | test.Equals(t, DiscardChanges, configuration.HandleUncommittedChanges) 164 | } 165 | 166 | func TestParseArgsTimerRoom(t *testing.T) { 167 | configuration := GetDefaultConfiguration() 168 | test.Equals(t, configuration.WipBranchQualifier, "") 169 | 170 | command, parameters, configuration := ParseArgs([]string{"mob", "timer", "10", "--room", "testroom"}, configuration) 171 | 172 | test.Equals(t, "timer", command) 173 | test.Equals(t, "10", strings.Join(parameters, "")) 174 | test.Equals(t, "testroom", configuration.TimerRoom) 175 | } 176 | 177 | func TestParseArgsTimerOpenRoom(t *testing.T) { 178 | configuration := GetDefaultConfiguration() 179 | test.Equals(t, configuration.WipBranchQualifier, "") 180 | 181 | command, parameters, configuration := ParseArgs([]string{"mob", "timer", "open", "--room", "testroom"}, configuration) 182 | 183 | test.Equals(t, "timer", command) 184 | test.Equals(t, "open", strings.Join(parameters, "")) 185 | test.Equals(t, "testroom", configuration.TimerRoom) 186 | } 187 | 188 | func TestMobRemoteNameEnvironmentVariable(t *testing.T) { 189 | configuration := setEnvVarAndParse("MOB_REMOTE_NAME", "GITHUB") 190 | test.Equals(t, "GITHUB", configuration.RemoteName) 191 | } 192 | 193 | func TestMobRemoteNameEnvironmentVariableEmptyString(t *testing.T) { 194 | configuration := setEnvVarAndParse("MOB_REMOTE_NAME", "") 195 | 196 | test.Equals(t, "origin", configuration.RemoteName) 197 | } 198 | 199 | func TestMobDoneSquashEnvironmentVariable(t *testing.T) { 200 | assertMobDoneSquashValue(t, "", Squash) 201 | assertMobDoneSquashValue(t, "garbage", Squash) 202 | assertMobDoneSquashValue(t, "squash", Squash) 203 | assertMobDoneSquashValue(t, "no-squash", NoSquash) 204 | assertMobDoneSquashValue(t, "squash-wip", SquashWip) 205 | } 206 | 207 | func assertMobDoneSquashValue(t *testing.T, value string, expected string) { 208 | configuration := setEnvVarAndParse("MOB_DONE_SQUASH", value) 209 | test.Equals(t, expected, configuration.DoneSquash) 210 | } 211 | 212 | func TestBooleanEnvironmentVariables(t *testing.T) { 213 | assertBoolEnvVarParsed(t, "MOB_START_CREATE", false, Configuration.GetMobStartCreateRemoteBranch) 214 | assertBoolEnvVarParsed(t, "MOB_NEXT_STAY", true, Configuration.GetMobNextStay) 215 | assertBoolEnvVarParsed(t, "MOB_REQUIRE_COMMIT_MESSAGE", false, Configuration.GetRequireCommitMessage) 216 | } 217 | 218 | func assertBoolEnvVarParsed(t *testing.T, envVar string, defaultValue bool, actual func(Configuration) bool) { 219 | t.Run(envVar, func(t *testing.T) { 220 | assertEnvVarParsed(t, envVar, "", defaultValue, boolToInterface(actual)) 221 | assertEnvVarParsed(t, envVar, "true", true, boolToInterface(actual)) 222 | assertEnvVarParsed(t, envVar, "false", false, boolToInterface(actual)) 223 | assertEnvVarParsed(t, envVar, "garbage", defaultValue, boolToInterface(actual)) 224 | }) 225 | } 226 | 227 | func assertEnvVarParsed(t *testing.T, variable string, value string, expected interface{}, actual func(Configuration) interface{}) { 228 | t.Run(fmt.Sprintf("%s=\"%s\"->(expects:%t)", variable, value, expected), func(t *testing.T) { 229 | configuration := setEnvVarAndParse(variable, value) 230 | test.Equals(t, expected, actual(configuration)) 231 | }) 232 | } 233 | 234 | func setEnvVarAndParse(variable string, value string) Configuration { 235 | os.Setenv(variable, value) 236 | defer os.Unsetenv(variable) 237 | 238 | return parseEnvironmentVariables(GetDefaultConfiguration()) 239 | } 240 | 241 | func boolToInterface(actual func(Configuration) bool) func(c Configuration) interface{} { 242 | return func(c Configuration) interface{} { 243 | return actual(c) 244 | } 245 | } 246 | 247 | func (c Configuration) GetMobDoneSquash() string { 248 | return c.DoneSquash 249 | } 250 | 251 | func (c Configuration) GetMobStartCreateRemoteBranch() bool { 252 | return c.StartCreate 253 | } 254 | 255 | func (c Configuration) GetMobNextStay() bool { 256 | return c.NextStay 257 | } 258 | 259 | func (c Configuration) GetRequireCommitMessage() bool { 260 | return c.RequireCommitMessage 261 | } 262 | 263 | func TestParseRequireCommitMessageEnvVariables(t *testing.T) { 264 | os.Unsetenv("MOB_REQUIRE_COMMIT_MESSAGE") 265 | defer os.Unsetenv("MOB_REQUIRE_COMMIT_MESSAGE") 266 | 267 | configuration := parseEnvironmentVariables(GetDefaultConfiguration()) 268 | test.Equals(t, false, configuration.RequireCommitMessage) 269 | 270 | os.Setenv("MOB_REQUIRE_COMMIT_MESSAGE", "false") 271 | configuration = parseEnvironmentVariables(GetDefaultConfiguration()) 272 | test.Equals(t, false, configuration.RequireCommitMessage) 273 | 274 | os.Setenv("MOB_REQUIRE_COMMIT_MESSAGE", "true") 275 | configuration = parseEnvironmentVariables(GetDefaultConfiguration()) 276 | test.Equals(t, true, configuration.RequireCommitMessage) 277 | } 278 | 279 | func TestReadUserConfigurationFromFileOverrideEverything(t *testing.T) { 280 | tempDir = t.TempDir() 281 | test.SetWorkingDir(tempDir) 282 | 283 | test.CreateFile(t, ".mob", ` 284 | MOB_CLI_NAME="team" 285 | MOB_REMOTE_NAME="gitlab" 286 | MOB_WIP_COMMIT_MESSAGE="team next" 287 | MOB_START_COMMIT_MESSAGE="mob: start" 288 | MOB_SKIP_CI_PUSH_OPTION_ENABLED=false 289 | MOB_REQUIRE_COMMIT_MESSAGE=true 290 | MOB_VOICE_COMMAND="whisper \"%s\"" 291 | MOB_VOICE_MESSAGE="team next" 292 | MOB_NOTIFY_COMMAND="/usr/bin/osascript -e 'display notification \"%s!!!\"'" 293 | MOB_NOTIFY_MESSAGE="team next" 294 | MOB_NEXT_STAY=false 295 | MOB_START_CREATE=true 296 | MOB_WIP_BRANCH_QUALIFIER="green" 297 | MOB_WIP_BRANCH_QUALIFIER_SEPARATOR="---" 298 | MOB_WIP_BRANCH_PREFIX="ensemble/" 299 | MOB_DONE_SQUASH=no-squash 300 | MOB_OPEN_COMMAND="idea %s" 301 | MOB_TIMER="123" 302 | MOB_TIMER_ROOM="Room_42" 303 | MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=true 304 | MOB_TIMER_LOCAL=false 305 | MOB_TIMER_USER="Mona" 306 | MOB_TIMER_URL="https://timer.innoq.io/" 307 | MOB_STASH_NAME="team-stash-name" 308 | `) 309 | actualConfiguration := parseUserConfiguration(GetDefaultConfiguration(), tempDir+"/.mob") 310 | test.Equals(t, "team", actualConfiguration.CliName) 311 | test.Equals(t, "gitlab", actualConfiguration.RemoteName) 312 | test.Equals(t, "team next", actualConfiguration.WipCommitMessage) 313 | test.Equals(t, "mob: start", actualConfiguration.StartCommitMessage) 314 | test.Equals(t, false, actualConfiguration.SkipCiPushOptionEnabled) 315 | test.Equals(t, true, actualConfiguration.RequireCommitMessage) 316 | test.Equals(t, "whisper \"%s\"", actualConfiguration.VoiceCommand) 317 | test.Equals(t, "team next", actualConfiguration.VoiceMessage) 318 | test.Equals(t, "/usr/bin/osascript -e 'display notification \"%s!!!\"'", actualConfiguration.NotifyCommand) 319 | test.Equals(t, "team next", actualConfiguration.NotifyMessage) 320 | test.Equals(t, false, actualConfiguration.NextStay) 321 | test.Equals(t, true, actualConfiguration.StartCreate) 322 | test.Equals(t, "green", actualConfiguration.WipBranchQualifier) 323 | test.Equals(t, "---", actualConfiguration.WipBranchQualifierSeparator) 324 | test.Equals(t, "ensemble/", actualConfiguration.WipBranchPrefix) 325 | test.Equals(t, NoSquash, actualConfiguration.DoneSquash) 326 | test.Equals(t, "idea %s", actualConfiguration.OpenCommand) 327 | test.Equals(t, "123", actualConfiguration.Timer) 328 | test.Equals(t, "Room_42", actualConfiguration.TimerRoom) 329 | test.Equals(t, true, actualConfiguration.TimerRoomUseWipBranchQualifier) 330 | test.Equals(t, false, actualConfiguration.TimerLocal) 331 | test.Equals(t, "Mona", actualConfiguration.TimerUser) 332 | test.Equals(t, "https://timer.innoq.io/", actualConfiguration.TimerUrl) 333 | test.Equals(t, "team-stash-name", actualConfiguration.StashName) 334 | 335 | test.CreateFile(t, ".mob", "\nMOB_TIMER_ROOM=\"Room\\\"\\\"_42\"\n") 336 | actualConfiguration1 := parseUserConfiguration(GetDefaultConfiguration(), tempDir+"/.mob") 337 | test.Equals(t, "Room\"\"_42", actualConfiguration1.TimerRoom) 338 | } 339 | 340 | func TestReadProjectConfigurationFromFileOverrideEverything(t *testing.T) { 341 | output := test.CaptureOutput(t) 342 | tempDir = t.TempDir() 343 | test.SetWorkingDir(tempDir) 344 | 345 | test.CreateFile(t, ".mob", ` 346 | MOB_CLI_NAME="team" 347 | MOB_REMOTE_NAME="gitlab" 348 | MOB_WIP_COMMIT_MESSAGE="team next" 349 | MOB_START_COMMIT_MESSAGE="mob: start" 350 | MOB_SKIP_CI_PUSH_OPTION_ENABLED=false 351 | MOB_REQUIRE_COMMIT_MESSAGE=true 352 | MOB_VOICE_COMMAND="whisper \"%s\"" 353 | MOB_VOICE_MESSAGE="team next" 354 | MOB_NOTIFY_COMMAND="/usr/bin/osascript -e 'display notification \"%s!!!\"'" 355 | MOB_NOTIFY_MESSAGE="team next" 356 | MOB_NEXT_STAY=false 357 | MOB_START_CREATE=true 358 | MOB_WIP_BRANCH_QUALIFIER="green" 359 | MOB_WIP_BRANCH_QUALIFIER_SEPARATOR="---" 360 | MOB_WIP_BRANCH_PREFIX="ensemble/" 361 | MOB_DONE_SQUASH=no-squash 362 | MOB_OPEN_COMMAND="idea %s" 363 | MOB_TIMER="123" 364 | MOB_TIMER_ROOM="Room_42" 365 | MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=true 366 | MOB_TIMER_LOCAL=false 367 | MOB_TIMER_USER="Mona" 368 | MOB_TIMER_URL="https://timer.innoq.io/" 369 | MOB_STASH_NAME="team-stash-name" 370 | `) 371 | actualConfiguration := parseProjectConfiguration(GetDefaultConfiguration(), tempDir+"/.mob") 372 | test.Equals(t, "team", actualConfiguration.CliName) 373 | test.Equals(t, "gitlab", actualConfiguration.RemoteName) 374 | test.Equals(t, "team next", actualConfiguration.WipCommitMessage) 375 | test.Equals(t, "mob: start", actualConfiguration.StartCommitMessage) 376 | test.Equals(t, false, actualConfiguration.SkipCiPushOptionEnabled) 377 | test.Equals(t, true, actualConfiguration.RequireCommitMessage) 378 | test.NotEquals(t, "whisper \"%s\"", actualConfiguration.VoiceCommand) 379 | test.NotEquals(t, "team next", actualConfiguration.VoiceMessage) 380 | test.NotEquals(t, "/usr/bin/osascript -e 'display notification \"%s!!!\"'", actualConfiguration.NotifyCommand) 381 | test.NotEquals(t, "team next", actualConfiguration.NotifyMessage) 382 | test.Equals(t, false, actualConfiguration.NextStay) 383 | test.Equals(t, true, actualConfiguration.StartCreate) 384 | test.Equals(t, "green", actualConfiguration.WipBranchQualifier) 385 | test.Equals(t, "---", actualConfiguration.WipBranchQualifierSeparator) 386 | test.Equals(t, "ensemble/", actualConfiguration.WipBranchPrefix) 387 | test.Equals(t, NoSquash, actualConfiguration.DoneSquash) 388 | test.NotEquals(t, "idea %s", actualConfiguration.OpenCommand) 389 | test.Equals(t, "123", actualConfiguration.Timer) 390 | test.Equals(t, "Room_42", actualConfiguration.TimerRoom) 391 | test.Equals(t, true, actualConfiguration.TimerRoomUseWipBranchQualifier) 392 | test.Equals(t, false, actualConfiguration.TimerLocal) 393 | test.Equals(t, "Mona", actualConfiguration.TimerUser) 394 | test.Equals(t, "https://timer.innoq.io/", actualConfiguration.TimerUrl) 395 | test.Equals(t, "team-stash-name", actualConfiguration.StashName) 396 | 397 | test.CreateFile(t, ".mob", "\nMOB_TIMER_ROOM=\"Room\\\"\\\"_42\"\n") 398 | actualConfiguration1 := parseUserConfiguration(GetDefaultConfiguration(), tempDir+"/.mob") 399 | test.Equals(t, "Room\"\"_42", actualConfiguration1.TimerRoom) 400 | test.AssertOutputContains(t, output, "Skipped overwriting key MOB_VOICE_COMMAND from project/.mob file out of security reasons!") 401 | test.AssertOutputContains(t, output, "Skipped overwriting key MOB_VOICE_MESSAGE from project/.mob file out of security reasons!") 402 | test.AssertOutputContains(t, output, "Skipped overwriting key MOB_NOTIFY_COMMAND from project/.mob file out of security reasons!") 403 | test.AssertOutputContains(t, output, "Skipped overwriting key MOB_NOTIFY_MESSAGE from project/.mob file out of security reasons!") 404 | test.AssertOutputContains(t, output, "Skipped overwriting key MOB_OPEN_COMMAND from project/.mob file out of security reasons!") 405 | } 406 | 407 | func TestReadConfigurationFromFileWithNonBooleanQuotedDoneSquashValue(t *testing.T) { 408 | say.TurnOnDebugging() 409 | tempDir = t.TempDir() 410 | test.SetWorkingDir(tempDir) 411 | 412 | test.CreateFile(t, ".mob", "\nMOB_DONE_SQUASH=\"squash-wip\"") 413 | actualConfiguration := parseUserConfiguration(GetDefaultConfiguration(), tempDir+"/.mob") 414 | test.Equals(t, SquashWip, actualConfiguration.DoneSquash) 415 | } 416 | 417 | func TestReadConfigurationFromFileAndSkipBrokenLines(t *testing.T) { 418 | say.TurnOnDebugging() 419 | tempDir = t.TempDir() 420 | test.SetWorkingDir(tempDir) 421 | 422 | test.CreateFile(t, ".mob", "\nMOB_TIMER_ROOM=\"Broken\" \"String\"") 423 | actualConfiguration := parseUserConfiguration(GetDefaultConfiguration(), tempDir+"/.mob") 424 | test.Equals(t, GetDefaultConfiguration().TimerRoom, actualConfiguration.TimerRoom) 425 | } 426 | 427 | func TestSkipIfConfigurationDoesNotExist(t *testing.T) { 428 | say.TurnOnDebugging() 429 | tempDir = t.TempDir() 430 | test.SetWorkingDir(tempDir) 431 | 432 | actualConfiguration := parseUserConfiguration(GetDefaultConfiguration(), tempDir+"/.mob") 433 | test.Equals(t, GetDefaultConfiguration(), actualConfiguration) 434 | } 435 | 436 | func TestSetMobDoneSquash(t *testing.T) { 437 | configuration := GetDefaultConfiguration() 438 | configuration.DoneSquash = Squash 439 | 440 | setMobDoneSquash(&configuration, "", "no-squash") 441 | test.Equals(t, NoSquash, configuration.DoneSquash) 442 | 443 | setMobDoneSquash(&configuration, "", "squash") 444 | test.Equals(t, Squash, configuration.DoneSquash) 445 | 446 | setMobDoneSquash(&configuration, "", "squash-wip") 447 | test.Equals(t, SquashWip, configuration.DoneSquash) 448 | } 449 | 450 | func TestSetMobDoneSquashGarbageValue(t *testing.T) { 451 | configuration := GetDefaultConfiguration() 452 | configuration.DoneSquash = NoSquash 453 | 454 | setMobDoneSquash(&configuration, "", "garbage") 455 | test.Equals(t, Squash, configuration.DoneSquash) 456 | } 457 | 458 | func TestSetMobDoneSquashEmptyStringValue(t *testing.T) { 459 | configuration := GetDefaultConfiguration() 460 | configuration.DoneSquash = NoSquash 461 | 462 | setMobDoneSquash(&configuration, "", "") 463 | test.Equals(t, Squash, configuration.DoneSquash) 464 | } 465 | -------------------------------------------------------------------------------- /docs/architecture.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | package main 4 | package find_next 5 | package coauthors 6 | package squash_wip 7 | 8 | main --> find_next 9 | main <-left-> coauthors 10 | main <-> squash_wip 11 | 12 | @enduml -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotemobprogramming/mob/78292ef4691b299e2a3d9783689c0f942406dde5/favicon.ico -------------------------------------------------------------------------------- /favicon_def.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotemobprogramming/mob/78292ef4691b299e2a3d9783689c0f942406dde5/favicon_def.ico -------------------------------------------------------------------------------- /find_next.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func findNextTypist(lastCommitters []string, gitUserName string) (nextTypist string, previousCommitters []string) { 4 | nextTypistNeverDifferentFromGitUser := true 5 | numberOfLastCommitters := len(lastCommitters) 6 | for i := 0; i < numberOfLastCommitters; i++ { 7 | if lastCommitters[i] == gitUserName && i > 0 { 8 | nextTypist = lastCommitters[i-1] 9 | if nextTypist != gitUserName { 10 | nextTypistNeverDifferentFromGitUser = false 11 | // '2*i+1' defines how far we look ahead. It is the number of already processed elements. 12 | lookaheadThreshold := min(2*i+1, len(lastCommitters)) 13 | previousTypist := lookahead(lastCommitters[:i], lastCommitters[i:lookaheadThreshold]) 14 | if previousTypist != "" { 15 | nextTypist = previousTypist 16 | } 17 | return 18 | } 19 | } 20 | // Do not add the last committer multiple times. 21 | if i == 0 || previousCommitters[0] != lastCommitters[i] { 22 | previousCommitters = prepend(previousCommitters, lastCommitters[i]) 23 | } 24 | } 25 | if nextTypist == "" { 26 | // Current committer is new to the session. 27 | numberOfPreviousCommitters := len(previousCommitters) 28 | if numberOfPreviousCommitters == 2 { 29 | nextTypist = previousCommitters[0] 30 | } else if numberOfPreviousCommitters > 2 { 31 | // Pick the next typist from the list of previous committers only. 32 | reversedPreviousCommitters := reverse(previousCommitters[:len(previousCommitters)-1]) 33 | nextTypist, _ = findNextTypist(reversedPreviousCommitters, reversedPreviousCommitters[0]) 34 | } 35 | } else if nextTypistNeverDifferentFromGitUser { 36 | // Someone mobs themselves. ;) 37 | nextTypist = "" 38 | } 39 | return nextTypist, nil 40 | } 41 | 42 | func reverse(list []string) []string { 43 | length := len(list) 44 | reversed := make([]string, length) 45 | for i := 0; i < length; i++ { 46 | reversed[length-1-i] = list[i] 47 | } 48 | return reversed 49 | } 50 | 51 | func lookahead(processedCommitters []string, previousCommitters []string) (nextTypist string) { 52 | for i := 0; i < len(previousCommitters); i++ { 53 | if !contains(processedCommitters, previousCommitters[i]) { 54 | nextTypist = previousCommitters[i] 55 | } 56 | } 57 | return 58 | } 59 | 60 | func contains(list []string, element string) bool { 61 | for i := 0; i < len(list); i++ { 62 | if list[i] == element { 63 | return true 64 | } 65 | } 66 | return false 67 | } 68 | 69 | func min(a int, b int) int { 70 | if a < b { 71 | return a 72 | } 73 | return b 74 | } 75 | 76 | func prepend(list []string, element string) []string { 77 | list = append(list, element) 78 | copy(list[1:], list) 79 | list[0] = element 80 | return list 81 | } 82 | -------------------------------------------------------------------------------- /find_next_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFindNextTypistNoCommits(t *testing.T) { 8 | lastCommitters := []string{} 9 | 10 | nextTypist, history := findNextTypist(lastCommitters, "alice") 11 | 12 | equals(t, nextTypist, "") 13 | equals(t, history, []string(nil)) 14 | } 15 | 16 | func TestFindNextTypistOnFirstCommit(t *testing.T) { 17 | lastCommitters := []string{"alice"} 18 | 19 | nextTypist, history := findNextTypist(lastCommitters, "alice") 20 | 21 | equals(t, nextTypist, "") 22 | equals(t, history, []string(nil)) 23 | } 24 | 25 | func TestFindNextTypistStartingWithFirstCommitterTwice(t *testing.T) { 26 | lastCommitters := []string{"alice", "alice"} 27 | 28 | nextTypist, history := findNextTypist(lastCommitters, "alice") 29 | 30 | equals(t, nextTypist, "") 31 | equals(t, history, []string(nil)) 32 | } 33 | 34 | func TestFindNextTypistOnlyCurrentCommitterInList(t *testing.T) { 35 | lastCommitters := []string{"alice", "alice", "alice"} 36 | 37 | nextTypist, history := findNextTypist(lastCommitters, "alice") 38 | 39 | equals(t, nextTypist, "") 40 | equals(t, history, []string(nil)) 41 | } 42 | 43 | func TestFindNextTypistCurrentCommitterAlternatingWithOneOtherPerson(t *testing.T) { 44 | lastCommitters := []string{"alice", "bob", "alice", "bob", "alice"} 45 | 46 | nextTypist, history := findNextTypist(lastCommitters, "alice") 47 | 48 | equals(t, nextTypist, "bob") 49 | equals(t, history, []string{"bob", "alice"}) 50 | } 51 | 52 | func TestFindNextTypistCommitterFirstSeenInFirstRound(t *testing.T) { 53 | lastCommitters := []string{"alice", "bob", "craig"} 54 | 55 | nextTypist, history := findNextTypist(lastCommitters, "alice") 56 | 57 | equals(t, nextTypist, "craig") 58 | equals(t, history, []string(nil)) 59 | } 60 | 61 | func TestFindNextTypistSecondCommitterFirstSeenRunningSession(t *testing.T) { 62 | lastCommitters := []string{"alice", "bob", "craig", "bob"} 63 | 64 | nextTypist, history := findNextTypist(lastCommitters, "alice") 65 | 66 | equals(t, nextTypist, "craig") 67 | equals(t, history, []string(nil)) 68 | } 69 | 70 | func TestFindNextTypistCurrentCommitterCommittedBefore(t *testing.T) { 71 | lastCommitters := []string{"alice", "alice", "bob", "alice"} 72 | 73 | nextTypist, history := findNextTypist(lastCommitters, "alice") 74 | 75 | equals(t, nextTypist, "bob") 76 | equals(t, history, []string{"bob", "alice"}) 77 | } 78 | 79 | func TestFindNextTypistThreeCommitters(t *testing.T) { 80 | lastCommitters := []string{"alice", "bob", "craig", "alice"} 81 | 82 | nextTypist, history := findNextTypist(lastCommitters, "alice") 83 | 84 | equals(t, nextTypist, "craig") 85 | equals(t, history, []string{"craig", "bob", "alice"}) 86 | } 87 | 88 | func TestFindNextTypistIgnoreMultipleCommitsFromSamePerson(t *testing.T) { 89 | lastCommitters := []string{"alice", "bob", "craig", "craig", "alice"} 90 | 91 | nextTypist, history := findNextTypist(lastCommitters, "alice") 92 | 93 | equals(t, nextTypist, "craig") 94 | equals(t, history, []string{"craig", "bob", "alice"}) 95 | } 96 | 97 | func TestFindNextTypistSuggestCommitterBeforeLastCommit(t *testing.T) { 98 | lastCommitters := []string{"alice", "bob", "craig", "alice", "bob", "dan"} 99 | 100 | nextTypist, history := findNextTypist(lastCommitters, "alice") 101 | 102 | equals(t, nextTypist, "dan") 103 | equals(t, history, []string{"craig", "bob", "alice"}) 104 | } 105 | 106 | func TestFindNextTypistSuggestCommitterBeforeLastCommitInThreshold(t *testing.T) { 107 | lastCommitters := []string{"alice", "bob", "craig", "alice", "bob", "dan", "erik", "fin"} 108 | 109 | nextTypist, history := findNextTypist(lastCommitters, "alice") 110 | 111 | equals(t, nextTypist, "erik") 112 | equals(t, history, []string{"craig", "bob", "alice"}) 113 | } 114 | 115 | func TestFindNextTypistIgnoreCommitterBeforeLastCommitOutsideThreshold(t *testing.T) { 116 | lastCommitters := []string{"alice", "bob", "craig", "alice", "craig", "bob", "alice", "fin"} 117 | 118 | nextTypist, history := findNextTypist(lastCommitters, "alice") 119 | 120 | equals(t, nextTypist, "craig") 121 | equals(t, history, []string{"craig", "bob", "alice"}) 122 | } 123 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/remotemobprogramming/mob/v5 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /goal/goal.go: -------------------------------------------------------------------------------- 1 | package goal 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | config "github.com/remotemobprogramming/mob/v5/configuration" 8 | "github.com/remotemobprogramming/mob/v5/httpclient" 9 | "github.com/remotemobprogramming/mob/v5/say" 10 | "io" 11 | "os" 12 | "strings" 13 | ) 14 | 15 | type GoalResponse struct { 16 | Goal string `json:"goal"` 17 | } 18 | 19 | type DeleteGoalRequest struct { 20 | User string `json:"user"` 21 | } 22 | 23 | type PutGoalRequest struct { 24 | Goal string `json:"goal"` 25 | User string `json:"user"` 26 | } 27 | 28 | func Goal(configuration config.Configuration, parameter []string) { 29 | if err := goal(configuration, parameter); err != nil { 30 | say.Error(err.Error()) 31 | exit(1) 32 | } 33 | } 34 | 35 | func goal(configuration config.Configuration, parameter []string) error { 36 | if configuration.TimerRoom == "" { 37 | return errors.New("No room specified. Set MOB_TIMER_ROOM to your timer.mob.sh room in .mob file.") 38 | } 39 | var err error 40 | if len(parameter) <= 0 { 41 | err = showGoal(configuration) 42 | } else if parameter[0] == "--delete" { 43 | err = deleteCurrentGoal(configuration) 44 | } else { 45 | err = setNewGoal(configuration, strings.Join(parameter, " ")) 46 | } 47 | if err != nil { 48 | return err 49 | } 50 | return nil 51 | } 52 | 53 | func setNewGoal(configuration config.Configuration, goal string) error { 54 | if err := putGoalHttp(goal, configuration); err != nil { 55 | say.Debug(err.Error()) 56 | return errors.New("Could not set new goal. An error occurred while sending the request.") 57 | } 58 | say.Info(fmt.Sprintf("Set new goal to \"%s\"", goal)) 59 | return nil 60 | } 61 | 62 | func putGoalHttp(goal string, configuration config.Configuration) error { 63 | requestBody, err := json.Marshal(PutGoalRequest{Goal: goal, User: configuration.TimerUser}) 64 | if err != nil { 65 | return err 66 | } 67 | client := httpclient.CreateHttpClient(configuration.TimerInsecure) 68 | _, err = client.SendRequest(requestBody, "PUT", getGoalUrl(configuration)) 69 | return err 70 | } 71 | 72 | func getGoalUrl(configuration config.Configuration) string { 73 | return configuration.TimerUrl + configuration.TimerRoom + "/goal" 74 | } 75 | 76 | func deleteCurrentGoal(configuration config.Configuration) error { 77 | err := deleteGoalHttp(configuration.TimerRoom, configuration.TimerUser, configuration.TimerUrl, configuration.TimerInsecure) 78 | if err != nil { 79 | say.Debug(err.Error()) 80 | return errors.New("Could not delete goal. An error occurred while sending the request.") 81 | } 82 | say.Info("Current goal has been deleted!") 83 | return nil 84 | } 85 | 86 | func deleteGoalHttp(room string, user string, timerService string, disableSslVerification bool) error { 87 | requestBody, err := json.Marshal(DeleteGoalRequest{User: user}) 88 | if err != nil { 89 | return err 90 | } 91 | client := httpclient.CreateHttpClient(disableSslVerification) 92 | _, err = client.SendRequest(requestBody, "DELETE", timerService+room+"/goal") 93 | return err 94 | } 95 | 96 | func showGoal(configuration config.Configuration) error { 97 | goal, err := getGoalHttp(configuration.TimerRoom, configuration.TimerUrl, configuration.TimerInsecure) 98 | if err != nil { 99 | say.Debug(err.Error()) 100 | return errors.New("Could not get goal. An error occurred while sending the request.") 101 | } 102 | if goal == "" { 103 | say.Fix("No goal set. To set a goal, use", configuration.Mob("goal ")) 104 | return nil 105 | } 106 | say.Info(goal) 107 | return nil 108 | } 109 | func getGoalHttp(room string, timerService string, disableSslVerification bool) (string, error) { 110 | url := timerService + room + "/goal" 111 | response, err := httpclient.GetNetHttpClient(disableSslVerification).Get(url) 112 | if err != nil { 113 | say.Debug(err.Error()) 114 | return "", err 115 | } 116 | if response.StatusCode >= 300 { 117 | return "", errors.New("got an error while requesting it: " + url + " " + response.Status) 118 | } 119 | if response.StatusCode == 204 { 120 | return "", nil 121 | } 122 | body, err := io.ReadAll(response.Body) 123 | if err != nil { 124 | say.Debug(err.Error()) 125 | return "", err 126 | } 127 | var goalResponse GoalResponse 128 | if err := json.Unmarshal(body, &goalResponse); err != nil { 129 | say.Debug(err.Error()) 130 | return "", err 131 | } 132 | return goalResponse.Goal, nil 133 | } 134 | 135 | var exit = func(code int) { 136 | os.Exit(code) 137 | } 138 | -------------------------------------------------------------------------------- /help/help.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | import ( 4 | config "github.com/remotemobprogramming/mob/v5/configuration" 5 | "github.com/remotemobprogramming/mob/v5/say" 6 | ) 7 | 8 | func Help(configuration config.Configuration) { 9 | output := configuration.CliName + ` enables a smooth Git handover 10 | 11 | Basic Commands: 12 | start Start session from base branch in wip branch 13 | next Handover changes in wip branch to next person 14 | done Squash all changes in wip branch to index in base branch 15 | reset Remove local and remote wip branch 16 | clean Removes all orphan wip branches 17 | 18 | Basic Commands with Options: 19 | start [] Start minutes timer 20 | [--include-uncommitted-changes|-i] Move uncommitted changes to wip branch 21 | [--discard-uncommitted-changes|-d] Discard uncommitted changes 22 | [--branch|-b ] Set wip branch to 'mob/` + configuration.WipBranchQualifierSeparator + `' 23 | [--create|-c] Create the remote branch 24 | [--join|-j] Join existing wip branch 25 | [--room ] Set room name for timer.mob.sh once 26 | next 27 | [--stay|-s] Stay on wip branch (default) 28 | [--return-to-base-branch|-r] Return to base branch 29 | [--message|-m ] Override commit message 30 | done 31 | [--no-squash] Squash no commits from wip branch, only merge wip branch 32 | [--squash] Squash all commits from wip branch 33 | [--squash-wip] Squash wip commits from wip branch, maintaining manual commits 34 | reset 35 | [--branch|-b ] Set wip branch to 'mob/` + configuration.WipBranchQualifierSeparator + `' 36 | 37 | Timer Commands: 38 | timer Start a timer 39 | [--room ] Set room name for timer.mob.sh once 40 | timer open Opens the timer website 41 | [--room ] Set room name for timer.mob.sh once 42 | start Start mob session in wip branch and a timer 43 | break Start a break timer 44 | goal Gives you the current goal of your timer.mob.sh room 45 | [] Sets the goal of your timer.mob.sh room 46 | [--delete] Deletes the goal of your timer.mob.sh room 47 | 48 | Short Commands (Options and descriptions as above): 49 | s Alias for 'start' 50 | n Alias for 'next' 51 | d Alias for 'done' 52 | b Alias for 'branch' 53 | t Alias for 'timer' 54 | g Alias for 'goal' 55 | 56 | Get more information: 57 | status Show status of the current session 58 | fetch Fetch remote state 59 | branch Show remote wip branches 60 | config Show all configuration options 61 | version Show tool version 62 | help Show help 63 | 64 | Other 65 | moo Moo! 66 | 67 | Add '--debug' to any option to enable verbose logging. 68 | Need more help? Join the community at slack.mob.sh 69 | ` 70 | say.Say(output) 71 | } 72 | -------------------------------------------------------------------------------- /httpclient/httpclient.go: -------------------------------------------------------------------------------- 1 | package httpclient 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "github.com/remotemobprogramming/mob/v5/say" 10 | "io/ioutil" 11 | "net/http" 12 | "net/url" 13 | ) 14 | 15 | type Client interface { 16 | SendRequest(method string, url string, body []byte) error 17 | } 18 | 19 | type HttpClient struct { 20 | netHttpClient *http.Client 21 | } 22 | 23 | func CreateHttpClient(disableSSLVerification bool) HttpClient { 24 | return HttpClient{ 25 | netHttpClient: GetNetHttpClient(disableSSLVerification), 26 | } 27 | } 28 | 29 | func GetNetHttpClient(disableSSLVerification bool) *http.Client { 30 | if disableSSLVerification { 31 | transCfg := &http.Transport{ 32 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 33 | } 34 | return &http.Client{Transport: transCfg} 35 | } 36 | return http.DefaultClient 37 | } 38 | 39 | func (c HttpClient) SendRequest(requestBody []byte, requestMethod string, requestUrl string) (string, error) { 40 | say.Info(requestMethod + " " + requestUrl + " " + string(requestBody)) 41 | 42 | responseBody := bytes.NewBuffer(requestBody) 43 | request, requestCreationError := http.NewRequest(requestMethod, requestUrl, responseBody) 44 | 45 | if requestCreationError != nil { 46 | return "", fmt.Errorf("failed to create the http request object: %w", requestCreationError) 47 | } 48 | 49 | request.Header.Set("Content-Type", "application/json") 50 | response, responseErr := c.netHttpClient.Do(request) 51 | if e, ok := responseErr.(*url.Error); ok { 52 | switch e.Err.(type) { 53 | case x509.UnknownAuthorityError: 54 | say.Error("The timer.mob.sh SSL certificate is signed by an unknown authority!") 55 | say.Fix("HINT: You can ignore that by adding MOB_TIMER_INSECURE=true to your configuration or environment.", 56 | "echo MOB_TIMER_INSECURE=true >> ~/.mob") 57 | return "", fmt.Errorf("failed, to make the http request: %w", responseErr) 58 | 59 | default: 60 | return "", fmt.Errorf("failed to make the http request: %w", responseErr) 61 | 62 | } 63 | } 64 | 65 | if responseErr != nil { 66 | return "", fmt.Errorf("failed to make the http request: %w", responseErr) 67 | } 68 | if response.StatusCode >= 300 { 69 | return "", errors.New("got an error from the server: " + requestUrl + " " + response.Status) 70 | } 71 | defer response.Body.Close() 72 | bodyBytes, responseReadingErr := ioutil.ReadAll(response.Body) 73 | body := string(bodyBytes) 74 | if responseReadingErr != nil { 75 | return "", fmt.Errorf("failed to read the http response: %w", responseReadingErr) 76 | } 77 | if string(body) != "" { 78 | say.Info(body) 79 | } 80 | return body, nil 81 | } 82 | -------------------------------------------------------------------------------- /install: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | target=/usr/local/bin/ 3 | go build && 4 | cp -f mob "$target" && 5 | echo "installed 'mob' in $target" 6 | -------------------------------------------------------------------------------- /install.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | @setlocal 3 | 4 | echo Installing 'mob' ... 5 | 6 | REM set target to the user's bin directory 7 | set target="%USERPROFILE%\bin" 8 | 9 | if not exist %target% ( 10 | md %target% 11 | echo Directory %target% created. 12 | ) 13 | 14 | go build 15 | copy mob.exe %target% 16 | echo 'mob.exe' installed to %target% 17 | 18 | REM add the user's bin directory to PATH, not used in current shell 19 | echo %path%|find /i "%USERPROFILE%\bin">nul || setx path "%path%;%USERPROFILE%\bin" 20 | 21 | echo 'mob' successfully installed. 22 | pause 23 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | target=/usr/local/bin 3 | user_arg=$1 4 | stream_cmd="curl -sL install.mob.sh" 5 | readme="https://mob.sh" 6 | 7 | determine_arch() { 8 | case "$(uname -s)" in 9 | Darwin) 10 | echo "universal" 11 | ;; 12 | *) 13 | echo "amd64" 14 | ;; 15 | esac 16 | } 17 | 18 | determine_os() { 19 | case "$(uname -s)" in 20 | Darwin) 21 | echo "darwin" 22 | ;; 23 | MINGW64*) 24 | echo "windows" 25 | ;; 26 | *) 27 | echo "linux" 28 | ;; 29 | esac 30 | } 31 | 32 | determine_user_install() { 33 | case "$(determine_os)" in 34 | windows) 35 | echo "--user" 36 | ;; 37 | *) 38 | echo "$user_arg" 39 | ;; 40 | esac 41 | } 42 | 43 | determine_local_target() { 44 | case "$(determine_os)" in 45 | windows) 46 | # shellcheck disable=SC1003 47 | echo "$USERPROFILE/bin" | tr '\\' '/' 48 | ;; 49 | linux) 50 | systemd-path user-binaries 51 | ;; 52 | esac 53 | } 54 | 55 | determine_mob_binary() { 56 | case "$(determine_os)" in 57 | windows) 58 | echo "mob.exe" 59 | ;; 60 | *) 61 | echo "mob" 62 | ;; 63 | esac 64 | } 65 | 66 | determine_ending() { 67 | case "$(determine_os)" in 68 | windows) 69 | echo "tar.gz" 70 | ;; 71 | *) 72 | echo "tar.gz" 73 | ;; 74 | esac 75 | } 76 | 77 | handle_user_installation() { 78 | user_install=$(determine_user_install) 79 | if [ "$user_install" = "--user" ]; then 80 | local_target=$(determine_local_target) 81 | if [ "$local_target" != "" ] && [ ! -d "$local_target" ]; then 82 | mkdir -p "$local_target" 83 | fi 84 | 85 | if [ -d "$local_target" ]; then 86 | target=$local_target 87 | else 88 | echo "unfortunately, there is no user-binaries path on your system. aborting installation." 89 | exit 1 90 | fi 91 | fi 92 | } 93 | 94 | check_access_rights() { 95 | if [ ! -w "$target" ]; then 96 | echo "you do not have access rights to $target." 97 | echo 98 | local_target=$(determine_local_target) 99 | if [ "$local_target" != "" ]; then 100 | echo "we recommend that you use the --user flag" 101 | echo "to install the app into your user binary path $local_target" 102 | echo 103 | echo " $stream_cmd | sh -s - --user" 104 | echo 105 | fi 106 | if [ "$(command -v sudo)" != "" ]; then 107 | echo "calling the installation with sudo might help." 108 | echo 109 | echo " $stream_cmd | sudo sh" 110 | echo 111 | fi 112 | exit 1 113 | fi 114 | } 115 | 116 | install_remote_binary() { 117 | echo "installing latest 'mob' release from GitHub to $target..." 118 | url=$(curl -s https://api.github.com/repos/remotemobprogramming/mob/releases/latest | 119 | grep "browser_download_url.*mob_.*$(determine_os)_$(determine_arch)\.$(determine_ending)" | 120 | cut -d ":" -f 2,3 | 121 | tr -d ' \"') 122 | curl -sSL "$url" | tar xz -C "$target" "$(determine_mob_binary)" && chmod +x "$target"/mob 123 | } 124 | 125 | add_to_path() { 126 | case "$(determine_os)" in 127 | windows) 128 | powershell -command "[System.Environment]::SetEnvironmentVariable('Path', [System.Environment]::GetEnvironmentVariable('Path', [System.EnvironmentVariableTarget]::User)+';$target', [System.EnvironmentVariableTarget]::User)" 129 | ;; 130 | esac 131 | } 132 | 133 | check_command() { 134 | location="$(command -v mob)" 135 | 136 | if [ $location = "" ]; then 137 | echo 138 | echo "(!) 'mob' could not be found after install!" 139 | 140 | case "$(determine_os)" in 141 | linux) 142 | echo " If you installed using --user it should be found when you login next time." 143 | echo " If it does not, you might need to manually add it to your .profile or equivalent like so:" 144 | echo 145 | echo " echo \"export PATH=$target:\\\$PATH\" >> ~/.profile" 146 | ;; 147 | *) 148 | echo " Make sure that $target is in your PATH" 149 | esac 150 | return 151 | fi 152 | 153 | echo "Mob binary location: $location" 154 | 155 | version="$(mob version)" 156 | echo "Mob binary version: $version" 157 | } 158 | 159 | check_say() { 160 | case "$(determine_os)" in 161 | linux) 162 | say=$(command -v say) 163 | if [ ! -e "$say" ]; then 164 | echo 165 | echo "Couldn't find a 'say' command on your system." 166 | echo "While 'mob' will still work, you won't get any spoken indication that your time is up." 167 | echo "Please refer to the documentation how to setup text to speech on a *NIX system:" 168 | echo 169 | echo " $readme#$(determine_os)-timer" 170 | echo 171 | fi 172 | ;; 173 | esac 174 | } 175 | 176 | check_installation_path() { 177 | location="$(command -v mob)" 178 | if [ "$(determine_os)" = "windows" ]; then 179 | location=$(echo $location | sed -E 's|^/([a-zA-Z])|\U\1:|') 180 | fi 181 | if [ "$location" != "$target/mob" ] && [ "$location" != "" ]; then 182 | echo "(!) The installation location doesn't match the location of the mob binary." 183 | echo " This means that the binary that's used is not the binary that has just been installed" 184 | echo " You probably want to delete the binary at $location" 185 | fi 186 | } 187 | 188 | main() { 189 | handle_user_installation 190 | check_access_rights 191 | install_remote_binary 192 | add_to_path 193 | check_command 194 | check_say 195 | check_installation_path 196 | } 197 | 198 | main 199 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 15 | 21 | 26 | 29 | 30 | -------------------------------------------------------------------------------- /logo_def.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | mobo3 9 | 14 | 20 | 25 | 28 | 29 | -------------------------------------------------------------------------------- /mob-configuration-example: -------------------------------------------------------------------------------- 1 | MOB_CLI_NAME="mob" 2 | MOB_REMOTE_NAME="origin" 3 | MOB_WIP_COMMIT_MESSAGE="mob next [ci-skip] [ci skip] [skip ci]" 4 | MOB_REQUIRE_COMMIT_MESSAGE=false 5 | MOB_VOICE_MESSAGE="mob next" 6 | MOB_NOTIFY_MESSAGE="mob next" 7 | MOB_NEXT_STAY=true 8 | MOB_WIP_BRANCH_QUALIFIER="" 9 | MOB_WIP_BRANCH_QUALIFIER_SEPARATOR="-" 10 | MOB_DONE_SQUASH=squash 11 | MOB_TIMER="10" 12 | MOB_TIMER_ROOM="my-room" 13 | MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=false 14 | MOB_TIMER_LOCAL=true 15 | MOB_TIMER_USER="" 16 | MOB_TIMER_URL="https://timer.mob.sh/" 17 | MOB_STASH_NAME="mob-stash-name" -------------------------------------------------------------------------------- /mob-social-media-preview-def.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotemobprogramming/mob/78292ef4691b299e2a3d9783689c0f942406dde5/mob-social-media-preview-def.png -------------------------------------------------------------------------------- /mob-social-media-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotemobprogramming/mob/78292ef4691b299e2a3d9783689c0f942406dde5/mob-social-media-preview.png -------------------------------------------------------------------------------- /mob.nix: -------------------------------------------------------------------------------- 1 | {withSpeech ? false, makeWrapper, espeak-ng, buildGoPackage, fetchFromGitHub, lib, ...}: 2 | buildGoPackage rec { 3 | pname = "mob.sh"; 4 | version = "1.3.0"; 5 | owner = "remotemobprogramming"; 6 | repo = "mob"; 7 | src = fetchFromGitHub { 8 | owner = owner; 9 | repo = repo; 10 | rev = "v${version}"; 11 | sha256 = "04x6cl2r4ja41cmy82p5apyavmdvak6jsclzf2l7islf0pmsnddv"; 12 | }; 13 | 14 | buildInputs = 15 | if withSpeech then 16 | [ espeak-ng makeWrapper ] 17 | else 18 | []; 19 | 20 | goPackagePath = "github.com/${owner}/${repo}/"; 21 | 22 | subPackages = [ "." ]; 23 | 24 | meta = { 25 | description = "Remote mob programming tool"; 26 | homepage = "https://mob.sh"; 27 | license = lib.licenses.mit; 28 | }; 29 | 30 | preFixup = if withSpeech then '' 31 | wrapProgram "$out/bin/mob" --set MOB_VOICE_COMMAND "${espeak-ng.out}/bin/espeak" 32 | '' else ""; 33 | 34 | } 35 | -------------------------------------------------------------------------------- /open/open.go: -------------------------------------------------------------------------------- 1 | package open 2 | 3 | var OpenInBrowser = func(url string) error { 4 | return open(url) 5 | } 6 | -------------------------------------------------------------------------------- /open/open_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package open 5 | 6 | import "os/exec" 7 | 8 | func open(url string) error { 9 | return exec.Command("open", url).Run() 10 | } 11 | -------------------------------------------------------------------------------- /open/open_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !darwin 2 | // +build !windows,!darwin 3 | 4 | package open 5 | 6 | import "os/exec" 7 | 8 | func open(url string) error { 9 | return exec.Command("xdg-open", url).Run() 10 | } 11 | -------------------------------------------------------------------------------- /open/open_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package open 5 | 6 | import ( 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | ) 11 | 12 | var ( 13 | cmd = "url.dll,FileProtocolHandler" 14 | runDll32 = filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe") 15 | ) 16 | 17 | func open(input string) error { 18 | return exec.Command(runDll32, cmd, input).Run() 19 | } 20 | -------------------------------------------------------------------------------- /reinstall: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | file=/usr/local/bin/mob 3 | if test -f "$file"; then 4 | sudo rm "$file" 5 | fi 6 | sudo ./install 7 | 8 | -------------------------------------------------------------------------------- /say/say.go: -------------------------------------------------------------------------------- 1 | package say 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | var isDebug = false // override with --debug parameter 9 | 10 | func TurnOnDebugging() { 11 | isDebug = true 12 | } 13 | 14 | func TurnOnDebuggingByArgs(args []string) { 15 | // debug needs to be parsed at the beginning to have DEBUG enabled as quickly as possible 16 | // otherwise, parsing others or other parameters don't have debug enabled 17 | for i := 0; i < len(args); i++ { 18 | if args[i] == "--debug" { 19 | isDebug = true 20 | } 21 | } 22 | } 23 | 24 | func Error(text string) { 25 | WithPrefix(text, "ERROR ") 26 | } 27 | 28 | func Warning(text string) { 29 | WithPrefix(text, "⚠ ") 30 | } 31 | 32 | func Info(text string) { 33 | WithPrefix(text, "> ") 34 | } 35 | 36 | func InfoIndented(text string) { 37 | WithPrefix(text, " ") 38 | } 39 | 40 | func Indented(text string) { 41 | WithPrefix(text, " ") 42 | } 43 | 44 | func Fix(instruction string, command string) { 45 | WithPrefix(instruction, "👉 ") 46 | emptyLine() 47 | Indented(command) 48 | emptyLine() 49 | } 50 | 51 | func Next(instruction string, command string) { 52 | WithPrefix(instruction, "👉 ") 53 | emptyLine() 54 | Indented(command) 55 | emptyLine() 56 | } 57 | 58 | func WithPrefix(s string, prefix string) { 59 | lines := strings.Split(strings.TrimSpace(s), "\n") 60 | for i := 0; i < len(lines); i++ { 61 | PrintToConsole(prefix + strings.TrimSpace(lines[i]) + "\n") 62 | } 63 | } 64 | 65 | func emptyLine() { 66 | PrintToConsole("\n") 67 | } 68 | 69 | func Say(s string) { 70 | if len(s) == 0 { 71 | return 72 | } 73 | PrintToConsole(strings.TrimRight(s, " \r\n\t\v\f") + "\n") 74 | } 75 | 76 | func Debug(text string) { 77 | if isDebug { 78 | WithPrefix(text, "DEBUG ") 79 | } 80 | } 81 | 82 | var PrintToConsole = func(message string) { 83 | fmt.Print(message) 84 | } 85 | -------------------------------------------------------------------------------- /snap/local/mob-launcher: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | if snapctl is-connected ssh-keys; then 3 | HOME=$(getent passwd $(id -u) | cut -d ':' -f 6); mob "$@" 4 | else 5 | echo " 👉 mob-sh has no access to ssh-keys, please run" 6 | echo "" 7 | echo " sudo snap connect mob-sh:ssh-keys" 8 | echo "" 9 | exit 1 10 | fi 11 | 12 | -------------------------------------------------------------------------------- /snap/local/say: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | espeak --path=$SNAP/usr/lib/x86_64-linux-gnu -v us-mbrola-1 "$@" 3 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: mob-sh 2 | base: core18 3 | version: "4.2.0" 4 | summary: Fast git handover with mob 5 | description: | 6 | Smooth git handover with 'mob' for remote mob or pair programming. 7 | See mob.sh for details. 8 | 9 | grade: stable 10 | confinement: strict 11 | 12 | parts: 13 | mob-sh: 14 | plugin: go 15 | source: . 16 | source-type: git 17 | stage-packages: 18 | - git 19 | - openssh-client 20 | - libnotify-bin 21 | 22 | local: 23 | plugin: dump 24 | source: snap/local 25 | source-type: local 26 | stage-packages: 27 | - espeak-ng-espeak 28 | - mbrola-us1 29 | override-build: | 30 | snapcraftctl build 31 | sed -i 's|/usr|$SNAP/usr|g' $SNAPCRAFT_PART_INSTALL/usr/bin/mbrola 32 | organize: 33 | say: bin/say 34 | mob-launcher: bin/mob-launcher 35 | usr/share/mbrola/us1/us1: usr/lib/x86_64-linux-gnu/espeak-ng-data/mbrola/us1 36 | 37 | plugs: 38 | gitconfig: 39 | interface: personal-files 40 | read: 41 | - $HOME/.gitconfig 42 | - $HOME/.config/git/config 43 | dot-gitignore: 44 | interface: personal-files 45 | read: 46 | - $HOME/.gitignore 47 | - $HOME/.gitignore_global 48 | dot-mob: 49 | interface: personal-files 50 | read: 51 | - $HOME/.mob 52 | 53 | apps: 54 | mob-sh: 55 | command: bin/mob-launcher 56 | plugs: 57 | - network 58 | - home 59 | - removable-media 60 | - ssh-keys 61 | - gitconfig 62 | - dot-gitignore 63 | - audio-playback 64 | - desktop 65 | - x11 66 | environment: 67 | GIT_TEMPLATE_DIR: $SNAP/usr/share/git-core/templates 68 | GIT_EXEC_PATH: $SNAP/usr/lib/git-core 69 | -------------------------------------------------------------------------------- /squash_wip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | config "github.com/remotemobprogramming/mob/v5/configuration" 5 | "github.com/remotemobprogramming/mob/v5/say" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | type Replacer func(string) string 14 | 15 | func squashWip(configuration config.Configuration) { 16 | if hasUncommittedChanges() { 17 | makeWipCommit(configuration) 18 | } 19 | currentBaseBranch, currentWipBranch := determineBranches(gitCurrentBranch(), gitBranches(), configuration) 20 | mergeBase := silentgit("merge-base", currentWipBranch.String(), currentBaseBranch.remote(configuration).String()) 21 | 22 | originalGitEditor, originalGitSequenceEditor := getEnvGitEditor() 23 | setEnvGitEditor( 24 | mobExecutable()+" squash-wip --git-editor", 25 | mobExecutable()+" squash-wip --git-sequence-editor", 26 | ) 27 | say.Info("rewriting history of '" + currentWipBranch.String() + "': squashing wip commits while keeping manual commits.") 28 | git("rebase", "--interactive", "--keep-empty", mergeBase) 29 | setEnvGitEditor(originalGitEditor, originalGitSequenceEditor) 30 | say.Info("resulting history is:") 31 | sayLastCommitsWithMessage(currentBaseBranch.remote(configuration).String(), currentWipBranch.String()) 32 | if lastCommitIsWipCommit(configuration) { // last commit is wip commit 33 | say.Info("undoing the final wip commit and staging its changes:") 34 | git("reset", "--soft", "HEAD^") 35 | } 36 | 37 | git("push", "--force", gitHooksOption(configuration)) 38 | } 39 | 40 | func lastCommitIsWipCommit(configuration config.Configuration) bool { 41 | return strings.HasPrefix(lastCommitMessage(), configuration.WipCommitMessage) 42 | } 43 | 44 | func lastCommitMessage() string { 45 | return silentgit("log", "-1", "--pretty=format:%B") 46 | } 47 | 48 | func sayLastCommitsWithMessage(currentBaseBranch string, currentWipBranch string) { 49 | commitsBaseWipBranch := currentBaseBranch + ".." + currentWipBranch 50 | log := silentgit("--no-pager", "log", commitsBaseWipBranch, "--pretty=oneline", "--abbrev-commit") 51 | lines := strings.Split(log, "\n") 52 | if len(lines) > 10 { 53 | say.Info("wip branch '" + currentWipBranch + "' contains " + strconv.Itoa(len(lines)) + " commits. The last 10 were:") 54 | lines = lines[:10] 55 | } 56 | output := strings.Join(lines, "\n") 57 | say.Say(output) 58 | } 59 | 60 | func setEnvGitEditor(gitEditor string, gitSequenceEditor string) { 61 | os.Setenv("GIT_EDITOR", gitEditor) 62 | os.Setenv("GIT_SEQUENCE_EDITOR", gitSequenceEditor) 63 | } 64 | 65 | func getEnvGitEditor() (gitEditor string, gitSequenceEditor string) { 66 | gitEditor = os.Getenv("GIT_EDITOR") 67 | gitSequenceEditor = os.Getenv("GIT_SEQUENCE_EDITOR") 68 | return 69 | } 70 | 71 | func mobExecutable() string { 72 | if isTestEnvironment() { 73 | wd, _ := os.Getwd() 74 | // Convert Windows path separators to /, so they work in git bash. No-op for non-Windows paths. 75 | wd = filepath.ToSlash(wd) 76 | return "cd " + wd + " && go run $(ls -1 ./*.go | grep -v _test.go)" 77 | } else { 78 | return "mob" 79 | } 80 | } 81 | 82 | func isTestEnvironment() bool { 83 | cliName := currentCliName(os.Args[0]) 84 | return strings.HasSuffix(cliName, ".test") || 85 | strings.HasSuffix(cliName, "_test") || 86 | os.Args[1] == "-test.v" 87 | } 88 | 89 | // used for non-interactive fixing of commit messages of squashed commits 90 | func squashWipGitEditor(fileName string, configuration config.Configuration) { 91 | replaceFileContents(fileName, func(input string) string { 92 | return commentWipCommits(input, configuration) 93 | }) 94 | } 95 | 96 | // used for non-interactive rebase to squash post-wip-commits 97 | func squashWipGitSequenceEditor(fileName string, configuration config.Configuration) { 98 | replaceFileContents(fileName, func(input string) string { 99 | result := markPostWipCommitsForSquashing(input, configuration) 100 | return markStartCommitForDropping(result, configuration) 101 | }) 102 | } 103 | 104 | func replaceFileContents(fileName string, replacer Replacer) { 105 | file, _ := os.OpenFile(fileName, os.O_RDWR, 0666) 106 | input, err := io.ReadAll(file) 107 | if err != nil { 108 | panic(err) 109 | } 110 | 111 | result := replacer(string(input)) 112 | 113 | file.Seek(0, io.SeekStart) 114 | file.Truncate(0) 115 | file.WriteString(result) 116 | file.Close() 117 | } 118 | 119 | func commentWipCommits(input string, configuration config.Configuration) string { 120 | var result []string 121 | ignoreBlock := false 122 | lines := strings.Split(input, "\n") 123 | for idx, line := range lines { 124 | if configuration.IsWipCommitMessage(line) { 125 | ignoreBlock = true 126 | } else if line == "" && isNextLineComment(lines, idx) { 127 | ignoreBlock = false 128 | } 129 | 130 | if ignoreBlock { 131 | result = append(result, "# "+line) 132 | } else { 133 | result = append(result, line) 134 | } 135 | } 136 | return strings.Join(result, "\n") 137 | } 138 | 139 | func isNextLineComment(lines []string, currentLineIndex int) bool { 140 | return len(lines) > currentLineIndex+1 && strings.HasPrefix(lines[currentLineIndex+1], "#") 141 | } 142 | 143 | func markPostWipCommitsForSquashing(input string, configuration config.Configuration) string { 144 | var result []string 145 | 146 | inputLines := strings.Split(input, "\n") 147 | for index := range inputLines { 148 | markedLine := markLine(inputLines, index, configuration) 149 | result = append(result, markedLine) 150 | } 151 | 152 | return strings.Join(result, "\n") 153 | } 154 | 155 | func markStartCommitForDropping(input string, configuration config.Configuration) string { 156 | inputLines := strings.Split(input, "\n") 157 | result := inputLines 158 | 159 | firstLine := inputLines[0] 160 | if isStartCISkipCommitLine(firstLine, configuration) { 161 | markedLine := markDrop(firstLine) 162 | result[0] = markedLine 163 | } 164 | 165 | return strings.Join(result, "\n") 166 | } 167 | 168 | func markLine(inputLines []string, i int, configuration config.Configuration) string { 169 | var resultLine = inputLines[i] 170 | previousLine := previousLine(inputLines, i) 171 | if isWipCommitLine(previousLine, configuration) { 172 | forthComingLines := inputLines[i:] 173 | 174 | if hasOnlyWipCommits(forthComingLines, configuration) { 175 | resultLine = markFixup(inputLines[i]) 176 | } else { 177 | resultLine = markSquash(inputLines[i]) 178 | } 179 | } 180 | return resultLine 181 | } 182 | 183 | func previousLine(inputLines []string, currentIndex int) string { 184 | var previousLine = "" 185 | if currentIndex > 0 { 186 | previousLine = inputLines[currentIndex-1] 187 | } 188 | return previousLine 189 | } 190 | 191 | func hasOnlyWipCommits(forthComingLines []string, configuration config.Configuration) bool { 192 | var onlyWipCommits = true 193 | for _, forthComingLine := range forthComingLines { 194 | if isPick(forthComingLine) && isManualCommit(forthComingLine, configuration) { 195 | onlyWipCommits = false 196 | } 197 | } 198 | return onlyWipCommits 199 | } 200 | 201 | func markSquash(line string) string { 202 | return strings.Replace(line, "pick ", "squash ", 1) 203 | } 204 | 205 | func markFixup(line string) string { 206 | return strings.Replace(line, "pick ", "fixup ", 1) 207 | } 208 | 209 | func markDrop(line string) string { 210 | return strings.Replace(line, "pick ", "drop ", 1) 211 | } 212 | 213 | func isWipCommitLine(line string, configuration config.Configuration) bool { 214 | return isPick(line) && isWipCommit(line, configuration) 215 | } 216 | 217 | func isStartCISkipCommitLine(line string, configuration config.Configuration) bool { 218 | return isPick(line) && isStartCISkipCommit(line, configuration) 219 | } 220 | 221 | func isManualCommit(line string, configuration config.Configuration) bool { 222 | return !isWipCommit(line, configuration) && !isStartCISkipCommit(line, configuration) 223 | } 224 | 225 | func isWipCommit(line string, configuration config.Configuration) bool { 226 | return strings.Contains(line, configuration.WipCommitMessage) 227 | } 228 | 229 | func isStartCISkipCommit(line string, configuration config.Configuration) bool { 230 | return strings.Contains(line, configuration.StartCommitMessage) 231 | } 232 | 233 | func isPick(line string) bool { 234 | return strings.HasPrefix(line, "pick ") 235 | } 236 | -------------------------------------------------------------------------------- /squash_wip_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | config "github.com/remotemobprogramming/mob/v5/configuration" 6 | "os" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestSquashWipCommits_acceptance(t *testing.T) { 12 | _, configuration := setup(t) 13 | wipCommit(t, configuration, "file1.txt") 14 | manualCommit(t, configuration, "file2.txt", "first manual commit") 15 | 16 | // manual commit followed by a wip commit 17 | start(configuration) 18 | createFileAndCommitIt(t, "file3.txt", "contentIrrelevant", "second manual commit") 19 | createFile(t, "file4.txt", "contentIrrelevant") 20 | next(configuration) 21 | 22 | // final manual commit 23 | start(configuration) 24 | createFileAndCommitIt(t, "file5.txt", "contentIrrelevant", "third manual commit") 25 | 26 | squashWip(configuration) 27 | 28 | assertOnBranch(t, "mob-session") 29 | equals(t, []string{ 30 | "third manual commit", 31 | "second manual commit", 32 | "first manual commit", 33 | }, commitsOnCurrentBranch(configuration)) 34 | equals(t, commitsOnCurrentBranch(configuration), commitsOnRemoteBranch(configuration)) 35 | } 36 | 37 | func TestSquashWipCommits_withFinalWipCommit(t *testing.T) { 38 | _, configuration := setup(t) 39 | wipCommit(t, configuration, "file1.txt") 40 | manualCommit(t, configuration, "file2.txt", "first manual commit") 41 | wipCommit(t, configuration, "file3.txt") 42 | start(configuration) 43 | 44 | squashWip(configuration) 45 | 46 | assertOnBranch(t, "mob-session") 47 | assertGitStatus(t, GitStatus{ 48 | "file3.txt": "A", 49 | }) 50 | equals(t, []string{ 51 | "first manual commit", 52 | }, commitsOnCurrentBranch(configuration)) 53 | } 54 | 55 | func TestSquashWipCommits_withManyFinalWipCommits(t *testing.T) { 56 | _, configuration := setup(t) 57 | wipCommit(t, configuration, "file1.txt") 58 | manualCommit(t, configuration, "file2.txt", "first manual commit") 59 | wipCommit(t, configuration, "file3.txt") 60 | wipCommit(t, configuration, "file4.txt") 61 | start(configuration) 62 | 63 | squashWip(configuration) 64 | 65 | assertOnBranch(t, "mob-session") 66 | assertGitStatus(t, GitStatus{ 67 | "file3.txt": "A", 68 | "file4.txt": "A", 69 | }) 70 | equals(t, []string{ 71 | "first manual commit", 72 | }, commitsOnCurrentBranch(configuration)) 73 | } 74 | 75 | func TestSquashWipCommits_onlyWipCommits(t *testing.T) { 76 | _, configuration := setup(t) 77 | wipCommit(t, configuration, "file1.txt") 78 | wipCommit(t, configuration, "file2.txt") 79 | wipCommit(t, configuration, "file3.txt") 80 | start(configuration) 81 | 82 | squashWip(configuration) 83 | 84 | assertOnBranch(t, "mob-session") 85 | assertGitStatus(t, GitStatus{ 86 | "file1.txt": "A", 87 | "file2.txt": "A", 88 | "file3.txt": "A", 89 | }) 90 | equals(t, []string{""}, commitsOnCurrentBranch(configuration)) 91 | } 92 | 93 | func TestSquashWipCommits_uncommittedModificationOfCommittedFile(t *testing.T) { 94 | _, configuration := setup(t) 95 | manualCommit(t, configuration, "file1.txt", "first manual commit") 96 | start(configuration) 97 | createFile(t, "file1.txt", "change") 98 | 99 | squashWip(configuration) 100 | 101 | assertOnBranch(t, "mob-session") 102 | assertGitStatus(t, GitStatus{ 103 | "file1.txt": "M", 104 | }) 105 | equals(t, []string{"first manual commit"}, commitsOnCurrentBranch(configuration)) 106 | } 107 | 108 | func TestSquashWipCommits_resetsEnv(t *testing.T) { 109 | _, configuration := setup(t) 110 | start(configuration) 111 | createFileAndCommitIt(t, "file1.txt", "contentIrrelevant", "new file") 112 | originalGitEditor := "irrelevant" 113 | originalGitSequenceEditor := "irrelevant, too" 114 | os.Setenv("GIT_EDITOR", originalGitEditor) 115 | os.Setenv("GIT_SEQUENCE_EDITOR", originalGitSequenceEditor) 116 | 117 | squashWip(configuration) 118 | 119 | equals(t, originalGitEditor, os.Getenv("GIT_EDITOR")) 120 | equals(t, originalGitSequenceEditor, os.Getenv("GIT_SEQUENCE_EDITOR")) 121 | } 122 | 123 | func TestSquashWipCommits_worksWithEmptyCommits(t *testing.T) { 124 | _, configuration := setup(t) 125 | wipCommit(t, configuration, "file1.txt") 126 | 127 | start(configuration) 128 | silentgit("commit", "--allow-empty", "-m ok") 129 | 130 | squashWip(configuration) 131 | 132 | assertOnBranch(t, "mob-session") 133 | equals(t, []string{ 134 | "ok", 135 | }, commitsOnCurrentBranch(configuration)) 136 | } 137 | 138 | func TestSquashWipCommits_acceptanceWithDroppingStartCommit(t *testing.T) { 139 | _, configuration := setup(t) 140 | wipCommit(t, configuration, "file1.txt") 141 | manualCommit(t, configuration, "file2.txt", "first manual commit") 142 | 143 | // manual commit followed by a wip commit 144 | start(configuration) 145 | createFileAndCommitIt(t, "file3.txt", "contentIrrelevant", "second manual commit") 146 | createFile(t, "file4.txt", "contentIrrelevant") 147 | next(configuration) 148 | 149 | // final manual commit 150 | start(configuration) 151 | createFileAndCommitIt(t, "file5.txt", "contentIrrelevant", "third manual commit") 152 | 153 | // Check if the initial commit for ci skip exists 154 | equals(t, []string{ 155 | "third manual commit", 156 | configuration.WipCommitMessage, 157 | "second manual commit", 158 | "first manual commit", 159 | configuration.WipCommitMessage, 160 | }, commitsOnCurrentBranch(configuration)) 161 | 162 | squashWip(configuration) 163 | 164 | assertOnBranch(t, "mob-session") 165 | equals(t, []string{ 166 | "third manual commit", 167 | "second manual commit", 168 | "first manual commit", 169 | }, commitsOnCurrentBranch(configuration)) 170 | equals(t, commitsOnCurrentBranch(configuration), commitsOnRemoteBranch(configuration)) 171 | } 172 | 173 | func TestCommitsOnCurrentBranch(t *testing.T) { 174 | _, configuration := setup(t) 175 | createFileAndCommitIt(t, "file1.txt", "contentIrrelevant", "not on branch") 176 | silentgit("push") 177 | start(configuration) 178 | createFileAndCommitIt(t, "file2.txt", "contentIrrelevant", "on branch") 179 | createFile(t, "file3.txt", "contentIrrelevant") 180 | next(configuration) 181 | start(configuration) 182 | 183 | commits := commitsOnCurrentBranch(configuration) 184 | 185 | equals(t, []string{ 186 | configuration.WipCommitMessage, 187 | "on branch", 188 | }, commits) 189 | } 190 | 191 | func TestMarkSquashWip_singleManualCommit(t *testing.T) { 192 | configuration := config.GetDefaultConfiguration() 193 | input := `pick c51a56d new file 194 | 195 | # Rebase ...` 196 | 197 | result := markPostWipCommitsForSquashing(input, configuration) 198 | 199 | equals(t, input, result) 200 | } 201 | 202 | func TestMarkSquashWip_manyManualCommits(t *testing.T) { 203 | configuration := config.GetDefaultConfiguration() 204 | input := `pick c51a56d new file 205 | pick 63ef7a4 another commit 206 | 207 | # Rebase ...` 208 | 209 | result := markPostWipCommitsForSquashing(input, configuration) 210 | 211 | equals(t, input, result) 212 | } 213 | 214 | func TestMarkSquashWip_wipCommitFollowedByManualCommit(t *testing.T) { 215 | configuration := config.GetDefaultConfiguration() 216 | input := fmt.Sprintf(`pick 01a9a31 %s 217 | pick c51a56d manual commit 218 | 219 | # Rebase ...`, configuration.WipCommitMessage) 220 | expected := fmt.Sprintf(`pick 01a9a31 %s 221 | squash c51a56d manual commit 222 | 223 | # Rebase ...`, configuration.WipCommitMessage) 224 | 225 | result := markPostWipCommitsForSquashing(input, configuration) 226 | 227 | equals(t, expected, result) 228 | } 229 | 230 | func TestMarkSquashWip_manyWipCommitsFollowedByManualCommit(t *testing.T) { 231 | configuration := config.GetDefaultConfiguration() 232 | input := fmt.Sprintf(`pick 01a9a31 %[1]s 233 | pick 01a9a32 %[1]s 234 | pick 01a9a33 %[1]s 235 | pick c51a56d manual commit 236 | 237 | # Rebase ...`, configuration.WipCommitMessage) 238 | expected := fmt.Sprintf(`pick 01a9a31 %[1]s 239 | squash 01a9a32 %[1]s 240 | squash 01a9a33 %[1]s 241 | squash c51a56d manual commit 242 | 243 | # Rebase ...`, configuration.WipCommitMessage) 244 | 245 | result := markPostWipCommitsForSquashing(input, configuration) 246 | 247 | equals(t, expected, result) 248 | } 249 | 250 | func TestMarkSquashWip_manualCommitFollowedByWipCommit(t *testing.T) { 251 | configuration := config.GetDefaultConfiguration() 252 | input := fmt.Sprintf(`pick c51a56d manual commit 253 | pick 01a9a31 %[1]s 254 | 255 | # Rebase ...`, configuration.WipCommitMessage) 256 | expected := fmt.Sprintf(`pick c51a56d manual commit 257 | pick 01a9a31 %[1]s 258 | 259 | # Rebase ...`, configuration.WipCommitMessage) 260 | 261 | result := markPostWipCommitsForSquashing(input, configuration) 262 | 263 | equals(t, expected, result) 264 | } 265 | 266 | func TestMarkSquashWip_manualCommitFollowedByManyWipCommits(t *testing.T) { 267 | configuration := config.GetDefaultConfiguration() 268 | input := fmt.Sprintf(`pick c51a56d manual commit 269 | pick 01a9a31 %[1]s 270 | pick 01a9a32 %[1]s 271 | pick 01a9a33 %[1]s 272 | 273 | # Rebase ...`, configuration.WipCommitMessage) 274 | expected := fmt.Sprintf(`pick c51a56d manual commit 275 | pick 01a9a31 %[1]s 276 | fixup 01a9a32 %[1]s 277 | fixup 01a9a33 %[1]s 278 | 279 | # Rebase ...`, configuration.WipCommitMessage) 280 | 281 | result := markPostWipCommitsForSquashing(input, configuration) 282 | 283 | equals(t, expected, result) 284 | } 285 | 286 | func TestMarkSquashWip_wipThenManualCommitFollowedByManyWipCommits(t *testing.T) { 287 | configuration := config.GetDefaultConfiguration() 288 | input := fmt.Sprintf(`pick 01a9a31 %[1]s 289 | pick c51a56d manual commit 290 | pick 01a9a32 %[1]s 291 | pick 01a9a33 %[1]s 292 | 293 | # Rebase ...`, configuration.WipCommitMessage) 294 | expected := fmt.Sprintf(`pick 01a9a31 %[1]s 295 | squash c51a56d manual commit 296 | pick 01a9a32 %[1]s 297 | fixup 01a9a33 %[1]s 298 | 299 | # Rebase ...`, configuration.WipCommitMessage) 300 | 301 | result := markPostWipCommitsForSquashing(input, configuration) 302 | 303 | equals(t, expected, result) 304 | } 305 | 306 | func TestMarkDropStartCommit_hasStartCISkipCommitLine(t *testing.T) { 307 | configuration := config.GetDefaultConfiguration() 308 | 309 | input := fmt.Sprintf(`pick 01a9a31 %[2]s 310 | pick c51a56d manual commit 311 | pick 01a9a32 %[1]s 312 | pick 01a9a33 %[1]s 313 | 314 | # Rebase ...`, configuration.WipCommitMessage, configuration.StartCommitMessage) 315 | expected := fmt.Sprintf(`drop 01a9a31 %[2]s 316 | pick c51a56d manual commit 317 | pick 01a9a32 %[1]s 318 | pick 01a9a33 %[1]s 319 | 320 | # Rebase ...`, configuration.WipCommitMessage, configuration.StartCommitMessage) 321 | 322 | result := markStartCommitForDropping(input, configuration) 323 | 324 | equals(t, expected, result) 325 | } 326 | 327 | // Check if the initial commit is not dropped when the commmit line does not contain `InitialCISkipCommitMessage` 328 | func TestMarkDropStartCommit_notHasStartCISkipCommitLine(t *testing.T) { 329 | configuration := config.GetDefaultConfiguration() 330 | 331 | input := fmt.Sprintf(`pick 01a9a31 %[1]s 332 | pick c51a56d manual commit 333 | pick 01a9a32 %[1]s 334 | pick 01a9a33 %[1]s 335 | 336 | # Rebase ...`, configuration.WipCommitMessage) 337 | expected := fmt.Sprintf(`pick 01a9a31 %[1]s 338 | pick c51a56d manual commit 339 | pick 01a9a32 %[1]s 340 | pick 01a9a33 %[1]s 341 | 342 | # Rebase ...`, configuration.WipCommitMessage) 343 | 344 | result := markStartCommitForDropping(input, configuration) 345 | 346 | equals(t, expected, result) 347 | } 348 | 349 | func TestCommentWipCommits_oneWipAndOneManualCommit(t *testing.T) { 350 | configuration := config.GetDefaultConfiguration() 351 | input := fmt.Sprintf(`# This is a combination of 2 commits. 352 | # This is the 1st commit message: 353 | 354 | %s 355 | 356 | # This is the commit message #2: 357 | 358 | manual commit 359 | 360 | # Please enter ...`, configuration.WipCommitMessage) 361 | expected := fmt.Sprintf(`# This is a combination of 2 commits. 362 | # This is the 1st commit message: 363 | 364 | # %s 365 | 366 | # This is the commit message #2: 367 | 368 | manual commit 369 | 370 | # Please enter ...`, configuration.WipCommitMessage) 371 | 372 | result := commentWipCommits(input, configuration) 373 | 374 | equals(t, expected, result) 375 | } 376 | 377 | func TestSquashWipCommitGitEditor(t *testing.T) { 378 | configuration := config.GetDefaultConfiguration() 379 | createTestbed(t, configuration) 380 | input := createFile(t, "commits", fmt.Sprintf( 381 | `# This is a combination of 2 commits. 382 | # This is the 1st commit message: 383 | 384 | %s 385 | 386 | # This is the commit message #2: 387 | 388 | new file 389 | 390 | # Please enter the commit message for your changes. Lines starting`, configuration.WipCommitMessage)) 391 | expected := fmt.Sprintf( 392 | `# This is a combination of 2 commits. 393 | # This is the 1st commit message: 394 | 395 | # %s 396 | 397 | # This is the commit message #2: 398 | 399 | new file 400 | 401 | # Please enter the commit message for your changes. Lines starting`, configuration.WipCommitMessage) 402 | 403 | squashWipGitEditor(input, config.GetDefaultConfiguration()) 404 | 405 | result, _ := os.ReadFile(input) 406 | equals(t, expected, string(result)) 407 | } 408 | 409 | func TestSquashWipCommitGitSequenceEditor(t *testing.T) { 410 | configuration := config.GetDefaultConfiguration() 411 | createTestbed(t, configuration) 412 | input := createFile(t, "rebase", fmt.Sprintf( 413 | `pick 01a9a31 %[1]s 414 | pick 01a9a32 %[1]s 415 | pick 01a9a33 %[1]s 416 | pick c51a56d manual commit 417 | 418 | # Rebase ... 419 | `, configuration.WipCommitMessage)) 420 | expected := fmt.Sprintf( 421 | `pick 01a9a31 %[1]s 422 | squash 01a9a32 %[1]s 423 | squash 01a9a33 %[1]s 424 | squash c51a56d manual commit 425 | 426 | # Rebase ... 427 | `, configuration.WipCommitMessage) 428 | 429 | squashWipGitSequenceEditor(input, config.GetDefaultConfiguration()) 430 | 431 | result, _ := os.ReadFile(input) 432 | equals(t, expected, string(result)) 433 | } 434 | 435 | func wipCommit(t *testing.T, configuration config.Configuration, filename string) { 436 | start(configuration) 437 | createFile(t, filename, "contentIrrelevant") 438 | next(configuration) 439 | } 440 | 441 | func manualCommit(t *testing.T, configuration config.Configuration, filename string, message string) { 442 | start(configuration) 443 | createFileAndCommitIt(t, filename, "contentIrrelevant", message) 444 | next(configuration) 445 | } 446 | 447 | func commitsOnCurrentBranch(configuration config.Configuration) []string { 448 | currentBaseBranch, currentWipBranch := determineBranches(gitCurrentBranch(), gitBranches(), configuration) 449 | commitsBaseWipBranch := currentBaseBranch.String() + ".." + currentWipBranch.String() 450 | log := silentgit("--no-pager", "log", commitsBaseWipBranch, "--pretty=format:%s") 451 | lines := strings.Split(log, "\n") 452 | return lines 453 | } 454 | 455 | func commitsOnRemoteBranch(configuration config.Configuration) []string { 456 | currentBaseBranch, currentWipBranch := determineBranches(gitCurrentBranch(), gitBranches(), configuration) 457 | commitsBaseWipBranch := currentBaseBranch.String() + ".." + configuration.RemoteName + "/" + currentWipBranch.String() 458 | log := silentgit("--no-pager", "log", commitsBaseWipBranch, "--pretty=format:%s") 459 | lines := strings.Split(log, "\n") 460 | return lines 461 | } 462 | -------------------------------------------------------------------------------- /status.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | config "github.com/remotemobprogramming/mob/v5/configuration" 5 | "github.com/remotemobprogramming/mob/v5/say" 6 | ) 7 | 8 | func status(configuration config.Configuration) { 9 | if isMobProgramming(configuration) { 10 | currentBaseBranch, currentWipBranch := determineBranches(gitCurrentBranch(), gitBranches(), configuration) 11 | say.Info("you are on wip branch " + currentWipBranch.String() + " (base branch " + currentBaseBranch.String() + ")") 12 | 13 | sayLastCommitsList(currentBaseBranch, currentWipBranch, configuration) 14 | } else { 15 | currentBaseBranch, _ := determineBranches(gitCurrentBranch(), gitBranches(), configuration) 16 | say.Info("you are on base branch '" + currentBaseBranch.String() + "'") 17 | showActiveMobSessions(configuration, currentBaseBranch) 18 | } 19 | } 20 | 21 | func showActiveMobSessions(configuration config.Configuration, currentBaseBranch Branch) { 22 | existingWipBranches := getWipBranchesForBaseBranch(currentBaseBranch, configuration) 23 | if len(existingWipBranches) > 0 { 24 | say.Info("remote wip branches detected:") 25 | for _, wipBranch := range existingWipBranches { 26 | time := silentgit("log", "-1", "--pretty=format:(%ar)", wipBranch) 27 | say.WithPrefix(wipBranch+" "+time, " - ") 28 | } 29 | } else { 30 | say.Info("no remote wip branches detected!") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /status_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | config "github.com/remotemobprogramming/mob/v5/configuration" 5 | "strconv" 6 | "testing" 7 | ) 8 | 9 | func TestExecuteKicksOffStatus(t *testing.T) { 10 | output, _ := setup(t) 11 | 12 | execute("status", []string{}, config.GetDefaultConfiguration()) 13 | 14 | assertOutputContains(t, output, "you are on base branch 'master'") 15 | } 16 | 17 | func TestStatusMobProgramming(t *testing.T) { 18 | output, configuration := setup(t) 19 | start(configuration) 20 | 21 | status(configuration) 22 | 23 | assertOutputContains(t, output, "you are on wip branch mob-session") 24 | } 25 | 26 | func TestStatusWithMoreThan5LinesOfLog(t *testing.T) { 27 | output, configuration := setup(t) 28 | configuration.NextStay = true 29 | start(configuration) 30 | 31 | for i := 0; i < 6; i++ { 32 | createFile(t, "test"+strconv.Itoa(i)+".txt", "contentIrrelevant") 33 | next(configuration) 34 | } 35 | 36 | status(configuration) 37 | assertOutputContains(t, output, "wip branch 'mob-session' contains 6 commits.") 38 | } 39 | 40 | func TestStatusDetectsWipBranches(t *testing.T) { 41 | output, configuration := setup(t) 42 | start(configuration) 43 | createFile(t, "test.txt", "contentIrrelevant") 44 | next(configuration) 45 | git("checkout", "master") 46 | 47 | status(configuration) 48 | 49 | assertOutputContains(t, output, "remote wip branches detected:\n - origin/mob-session") 50 | assertOutputContains(t, output, " second") 51 | assertOutputContains(t, output, " ago)") 52 | } 53 | -------------------------------------------------------------------------------- /test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/remotemobprogramming/mob/v5/say" 6 | "os" 7 | "path/filepath" 8 | "reflect" 9 | "runtime" 10 | "runtime/debug" 11 | "strings" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | var ( 17 | workingDir string 18 | ) 19 | 20 | const ( 21 | AWAIT_DEFAULT_POLL_INTERVAL = 100 * time.Millisecond 22 | AWAIT_DEFAULT_AT_MOST = 2 * time.Second 23 | ) 24 | 25 | func Equals(t *testing.T, exp, act interface{}) { 26 | if !reflect.DeepEqual(exp, act) { 27 | t.Log(string(debug.Stack())) 28 | failWithFailure(t, exp, act) 29 | } 30 | } 31 | 32 | func NotEquals(t *testing.T, exp, act interface{}) { 33 | if reflect.DeepEqual(exp, act) { 34 | t.Log(string(debug.Stack())) 35 | failWithFailure(t, exp, act) 36 | } 37 | } 38 | 39 | func failWithFailure(t *testing.T, exp interface{}, act interface{}) { 40 | _, file, line, _ := runtime.Caller(1) 41 | fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) 42 | t.FailNow() 43 | } 44 | 45 | func failWithFailureMessage(t *testing.T, message string) { 46 | _, file, line, _ := runtime.Caller(1) 47 | fmt.Printf("\033[31m%s:%d:\n\n\t%s", filepath.Base(file), line, message) 48 | t.FailNow() 49 | } 50 | 51 | func CreateFile(t *testing.T, filename string, content string) (pathToFile string) { 52 | contentAsBytes := []byte(content) 53 | pathToFile = workingDir + "/" + filename 54 | err := os.WriteFile(pathToFile, contentAsBytes, 0644) 55 | if err != nil { 56 | failWithFailure(t, "creating file "+filename+" with content "+content, "error") 57 | } 58 | return 59 | } 60 | 61 | func SetWorkingDir(dir string) { 62 | workingDir = dir 63 | say.Say("\n===== cd " + dir) 64 | } 65 | 66 | func CaptureOutput(t *testing.T) *string { 67 | messages := "" 68 | say.PrintToConsole = func(text string) { 69 | t.Log(strings.TrimRight(text, "\n")) 70 | messages += text 71 | } 72 | return &messages 73 | } 74 | 75 | func AssertOutputContains(t *testing.T, output *string, contains string) { 76 | currentOutput := *output 77 | if !strings.Contains(currentOutput, contains) { 78 | failWithFailure(t, "output contains '"+contains+"'", currentOutput) 79 | } 80 | } 81 | 82 | func AssertOutputNotContains(t *testing.T, output *string, notContains string) { 83 | if strings.Contains(*output, notContains) { 84 | failWithFailure(t, "output not contains "+notContains, output) 85 | } 86 | } 87 | 88 | func Await(t *testing.T, until func() bool, awaitedState string) { 89 | AwaitBlocking(t, AWAIT_DEFAULT_POLL_INTERVAL, AWAIT_DEFAULT_AT_MOST, until, awaitedState) 90 | } 91 | 92 | func AwaitBlocking(t *testing.T, pollInterval time.Duration, atMost time.Duration, until func() bool, awaitedState string) { 93 | if pollInterval <= 0 { 94 | failWithFailureMessage(t, fmt.Sprintf("PollInterval cannot be 0 or below, got: %d", pollInterval)) 95 | return 96 | } 97 | if atMost <= 0 { 98 | failWithFailureMessage(t, fmt.Sprintf("AtMost timeout cannot be 0 or below, got: %d", atMost)) 99 | return 100 | } 101 | if pollInterval > atMost { 102 | failWithFailureMessage(t, fmt.Sprintf("PollInterval must be smaller than atMost timeout, got: pollInterval=%d, atMost=%d", pollInterval, atMost)) 103 | return 104 | } 105 | startTime := time.Now() 106 | timeLeft := atMost 107 | 108 | for { 109 | if until() { 110 | return 111 | } else { 112 | timeLeft = atMost - time.Now().Sub(startTime) 113 | if timeLeft <= 0 { 114 | stackTrace := string(debug.Stack()) 115 | failWithFailureMessage(t, fmt.Sprintf("expected '%s' to occur within %s but did not: %s", awaitedState, atMost, stackTrace)) 116 | return 117 | } 118 | } 119 | time.Sleep(pollInterval) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /timer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | config "github.com/remotemobprogramming/mob/v5/configuration" 8 | "github.com/remotemobprogramming/mob/v5/httpclient" 9 | "github.com/remotemobprogramming/mob/v5/say" 10 | "runtime" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | func StartTimer(timerInMinutes string, configuration config.Configuration) { 16 | if err := startTimer(timerInMinutes, configuration); err != nil { 17 | Exit(1) 18 | } 19 | } 20 | 21 | func startTimer(timerInMinutes string, configuration config.Configuration) error { 22 | err, timeoutInMinutes := toMinutes(timerInMinutes) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | timeoutInSeconds := timeoutInMinutes * 60 28 | timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") 29 | say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) 30 | 31 | room := getMobTimerRoom(configuration) 32 | startRemoteTimer := room != "" 33 | startLocalTimer := configuration.TimerLocal 34 | 35 | if !startRemoteTimer && !startLocalTimer { 36 | say.Error("No timer configured, not starting timer") 37 | Exit(1) 38 | } 39 | 40 | if startRemoteTimer { 41 | timerUser := getUserForMobTimer(configuration.TimerUser) 42 | err := httpPutTimer(timeoutInMinutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure) 43 | if err != nil { 44 | say.Error("remote timer couldn't be started") 45 | say.Error(err.Error()) 46 | Exit(1) 47 | } 48 | } 49 | 50 | if startLocalTimer { 51 | err := executeCommandsInBackgroundProcess(getSleepCommand(timeoutInSeconds), getVoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), getNotifyCommand(configuration.NotifyMessage, configuration.NotifyCommand), "echo \"mobTimer\"") 52 | 53 | if err != nil { 54 | say.Error(fmt.Sprintf("timer couldn't be started on your system (%s)", runtime.GOOS)) 55 | say.Error(err.Error()) 56 | Exit(1) 57 | } 58 | } 59 | 60 | say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". Happy collaborating! :)") 61 | return nil 62 | } 63 | 64 | func getMobTimerRoom(configuration config.Configuration) string { 65 | if !isGit() { 66 | say.Debug("timer not in git repository, using MOB_TIMER_ROOM for room name") 67 | return configuration.TimerRoom 68 | } 69 | 70 | currentWipBranchQualifier := configuration.WipBranchQualifier 71 | if currentWipBranchQualifier == "" { 72 | currentBranch := gitCurrentBranch() 73 | currentBaseBranch, _ := determineBranches(currentBranch, gitBranches(), configuration) 74 | 75 | if currentBranch.IsWipBranch(configuration) { 76 | wipBranchWithoutWipPrefix := currentBranch.removeWipPrefix(configuration).Name 77 | currentWipBranchQualifier = removePrefix(removePrefix(wipBranchWithoutWipPrefix, currentBaseBranch.Name), configuration.WipBranchQualifierSeparator) 78 | } 79 | } 80 | 81 | if configuration.TimerRoomUseWipBranchQualifier && currentWipBranchQualifier != "" { 82 | say.Info("Using wip branch qualifier for room name") 83 | return currentWipBranchQualifier 84 | } 85 | 86 | return configuration.TimerRoom 87 | } 88 | 89 | func StartBreakTimer(timerInMinutes string, configuration config.Configuration) { 90 | if err := startBreakTimer(timerInMinutes, configuration); err != nil { 91 | Exit(1) 92 | } 93 | } 94 | 95 | func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { 96 | err, timeoutInMinutes := toMinutes(timerInMinutes) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | timeoutInSeconds := timeoutInMinutes * 60 102 | timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") 103 | say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) 104 | 105 | room := getMobTimerRoom(configuration) 106 | startRemoteTimer := room != "" 107 | startLocalTimer := configuration.TimerLocal 108 | 109 | if !startRemoteTimer && !startLocalTimer { 110 | say.Error("No break timer configured, not starting break timer") 111 | Exit(1) 112 | } 113 | 114 | if startRemoteTimer { 115 | timerUser := getUserForMobTimer(configuration.TimerUser) 116 | err := httpPutBreakTimer(timeoutInMinutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure) 117 | 118 | if err != nil { 119 | say.Error("remote break timer couldn't be started") 120 | say.Error(err.Error()) 121 | Exit(1) 122 | } 123 | } 124 | 125 | if startLocalTimer { 126 | err := executeCommandsInBackgroundProcess(getSleepCommand(timeoutInSeconds), getVoiceCommand("mob start", configuration.VoiceCommand), getNotifyCommand("mob start", configuration.NotifyCommand), "echo \"mobTimer\"") 127 | 128 | if err != nil { 129 | say.Error(fmt.Sprintf("break timer couldn't be started on your system (%s)", runtime.GOOS)) 130 | say.Error(err.Error()) 131 | Exit(1) 132 | } 133 | } 134 | 135 | say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min break timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". So take a break now! :)") 136 | return nil 137 | } 138 | 139 | func getUserForMobTimer(userOverride string) string { 140 | if userOverride == "" { 141 | return gitUserName() 142 | } 143 | return userOverride 144 | } 145 | 146 | func toMinutes(timerInMinutes string) (error, int) { 147 | timeoutInMinutes, err := strconv.Atoi(timerInMinutes) 148 | if err != nil || timeoutInMinutes < 1 { 149 | say.Error(fmt.Sprintf("The parameter must be an integer number greater then zero")) 150 | return errors.New("The parameter must be an integer number greater then zero"), 0 151 | } 152 | return nil, timeoutInMinutes 153 | } 154 | 155 | func httpPutTimer(timeoutInMinutes int, room string, user string, timerService string, disableSSLVerification bool) error { 156 | putBody, _ := json.Marshal(map[string]interface{}{ 157 | "timer": timeoutInMinutes, 158 | "user": user, 159 | }) 160 | client := httpclient.CreateHttpClient(disableSSLVerification) 161 | _, err := client.SendRequest(putBody, "PUT", timerService+room) 162 | return err 163 | } 164 | 165 | func httpPutBreakTimer(timeoutInMinutes int, room string, user string, timerService string, disableSSLVerification bool) error { 166 | putBody, _ := json.Marshal(map[string]interface{}{ 167 | "breaktimer": timeoutInMinutes, 168 | "user": user, 169 | }) 170 | client := httpclient.CreateHttpClient(disableSSLVerification) 171 | _, err := client.SendRequest(putBody, "PUT", timerService+room) 172 | return err 173 | } 174 | 175 | func getSleepCommand(timeoutInSeconds int) string { 176 | return fmt.Sprintf("sleep %d", timeoutInSeconds) 177 | } 178 | 179 | func getVoiceCommand(message string, voiceCommand string) string { 180 | if len(voiceCommand) == 0 { 181 | return "" 182 | } 183 | return injectCommandWithMessage(voiceCommand, message) 184 | } 185 | 186 | func getNotifyCommand(message string, notifyCommand string) string { 187 | if len(notifyCommand) == 0 { 188 | return "" 189 | } 190 | return injectCommandWithMessage(notifyCommand, message) 191 | } 192 | -------------------------------------------------------------------------------- /timer_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestOpenTimerInBrowserWithTimerRoom(t *testing.T) { 6 | mockOpenInBrowser() 7 | output, configuration := setup(t) 8 | configuration.TimerRoom = "testroom" 9 | 10 | err := openTimerInBrowser(configuration) 11 | 12 | assertOutputNotContains(t, output, "Timer Room is not configured.") 13 | assertNoError(t, err) 14 | } 15 | 16 | func TestOpenTimerInBrowserWithoutTimerRoom(t *testing.T) { 17 | mockOpenInBrowser() 18 | output, configuration := setup(t) 19 | 20 | err := openTimerInBrowser(configuration) 21 | 22 | assertOutputContains(t, output, "Timer Room is not configured.") 23 | assertNoError(t, err) 24 | } 25 | 26 | func TestOpenTimerInBrowserError(t *testing.T) { 27 | mockOpenInBrowser() 28 | _, configuration := setup(t) 29 | configuration.TimerUrl = "" 30 | 31 | err := openTimerInBrowser(configuration) 32 | 33 | assertError(t, err, "Timer url is not configured") 34 | } 35 | 36 | func TestTimerNumberLessThen1(t *testing.T) { 37 | output, configuration := setup(t) 38 | 39 | err := startTimer("0", configuration) 40 | 41 | assertError(t, err, "The parameter must be an integer number greater then zero") 42 | assertOutputContains(t, output, "The parameter must be an integer number greater then zero") 43 | } 44 | 45 | func TestTimerNotANumber(t *testing.T) { 46 | output, configuration := setup(t) 47 | 48 | err := startTimer("NotANumber", configuration) 49 | 50 | assertError(t, err, "The parameter must be an integer number greater then zero") 51 | assertOutputContains(t, output, "The parameter must be an integer number greater then zero") 52 | } 53 | 54 | func TestTimer(t *testing.T) { 55 | output, configuration := setup(t) 56 | configuration.NotifyCommand = "" 57 | configuration.VoiceCommand = "" 58 | 59 | err := startTimer("1", configuration) 60 | 61 | assertNoError(t, err) 62 | assertOutputContains(t, output, "1 min timer ends at approx.") 63 | assertOutputContains(t, output, "Happy collaborating! :)") 64 | } 65 | 66 | func TestTimerExportFunction(t *testing.T) { 67 | output, configuration := setup(t) 68 | configuration.NotifyCommand = "" 69 | configuration.VoiceCommand = "" 70 | 71 | StartTimer("1", configuration) 72 | 73 | assertOutputContains(t, output, "1 min timer ends at approx.") 74 | assertOutputContains(t, output, "Happy collaborating! :)") 75 | } 76 | 77 | func TestBreakTimerNumberLessThen1(t *testing.T) { 78 | output, configuration := setup(t) 79 | 80 | err := startBreakTimer("0", configuration) 81 | 82 | assertError(t, err, "The parameter must be an integer number greater then zero") 83 | assertOutputContains(t, output, "The parameter must be an integer number greater then zero") 84 | } 85 | 86 | func TestBreakTimerNotANumber(t *testing.T) { 87 | output, configuration := setup(t) 88 | 89 | err := startBreakTimer("NotANumber", configuration) 90 | 91 | assertError(t, err, "The parameter must be an integer number greater then zero") 92 | assertOutputContains(t, output, "The parameter must be an integer number greater then zero") 93 | } 94 | 95 | func TestBreakTimer(t *testing.T) { 96 | output, configuration := setup(t) 97 | configuration.NotifyCommand = "" 98 | configuration.VoiceCommand = "" 99 | 100 | err := startBreakTimer("1", configuration) 101 | 102 | assertNoError(t, err) 103 | assertOutputContains(t, output, "1 min break timer ends at approx.") 104 | assertOutputContains(t, output, "So take a break now! :)") 105 | } 106 | 107 | func TestBreakTimerExportFunction(t *testing.T) { 108 | output, configuration := setup(t) 109 | configuration.NotifyCommand = "" 110 | configuration.VoiceCommand = "" 111 | 112 | StartBreakTimer("5", configuration) 113 | 114 | assertOutputContains(t, output, "5 min break timer ends at approx.") 115 | assertOutputContains(t, output, "So take a break now! :)") 116 | } 117 | --------------------------------------------------------------------------------