├── .gitignore ├── .travis.yml ├── README.rst ├── _code ├── codecli ├── __init__.py ├── apic.py ├── commands │ ├── __init__.py │ ├── clone.py │ ├── config.py │ ├── end.py │ ├── fetch.py │ ├── fork.py │ ├── hotfix.py │ ├── merge.py │ ├── pullreq.py │ ├── start.py │ └── sync.py ├── providers │ ├── __init__.py │ ├── base.py │ ├── provider_code.py │ ├── provider_gitein.py │ ├── provider_github.py │ └── provider_gityashi.py └── utils.py ├── images ├── codecli-256.png └── codecli-512.png ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test_commands │ ├── test_clone.py │ └── test_fetch.py ├── test_utils.py └── utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[c|o] 2 | .*.swp 3 | *.egg-info/ 4 | *.egg 5 | /build/ 6 | /dist/ 7 | .ropeproject 8 | /.cache/ 9 | /.eggs/ 10 | /.tox/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.5" 6 | install: pip install tox-travis 7 | script: tox 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Command Line Tools for CODE 3 | =========================== 4 | 5 | .. image:: http://code.dapps.douban.com/codecli/raw/master/images/codecli-256.png 6 | 7 | 这是一个方便使用 `code`_ 进行合作开发的工具。 8 | 9 | .. _code: http://code.dapps.douban.com 10 | 11 | Install 12 | ======= 13 | 14 | 使用 virtualenv:: 15 | 16 | $ virtualenv codecli 17 | $ codecli/bin/pip install -e git+http://code.dapps.douban.com/codecli.git#egg=codecli 18 | $ ln -s `pwd`/codecli/bin/code $HOME/bin/ 19 | # make sure add $HOME/bin to your $PATH 20 | 21 | Usage 22 | ===== 23 | 24 | 创建本地clone 25 | ~~~~~~~~~~~~~~ 26 | 27 | 如果你要向一个仓库贡献代码,先在 code 上从其 fork 一份(这部分目前只能手工操作 28 | ,等code提供fork api后可自动进行),然后运行 29 | 30 | 31 | :: 32 | 33 | $ code fork {repo} {your_fork} {dir} 34 | $ cd dir 35 | 36 | 其中,repo 和 your_fork 只需要填写在 code 上的项目名即可,例如:: 37 | 38 | $ code fork codecli hongqn/codecli codecli 39 | 40 | ``your_fork``和``dir``可以忽略,默认值分别为``{user_name}/{repo}``和``{repo}``,例如:: 41 | 42 | $ code fork codecli 43 | 44 | 就等同于前一个例子(需要确保你在~/.codecli.conf里设置了user.email) 45 | 46 | 47 | 如果你只是想管理自己的仓库,而不是向其他人的仓库贡献代码,可以用 ``code 48 | clone`` 命令:: 49 | 50 | $ code clone codecli 51 | 52 | 53 | ``code fork`` 和 ``code clone`` 命令都会创建 ``origin`` 和 ``upstream`` 两个 54 | remote ,在 codecli 的其他命令中,会默认这两个 remote 均存在, ``origin`` 指向 55 | 你自己的fork, ``upstream`` 指向上游仓库(即你希望贡献代码的仓库)。对于使用 56 | ``code clone`` 的仓库而言, ``origin`` 和 ``upstream`` 均指向你自己的仓库。 57 | 58 | 同时, codecli 还会设置 user.email 和 user.name ,并保存在 ~/.codecli.conf 中 59 | 。之后每次使用 codecli 创建本地仓库时,都会自动从 ~/.codecli.conf 中读取之前保 60 | 存的用户信息。 61 | 62 | 63 | 开始一个分支 64 | ~~~~~~~~~~~~ 65 | 66 | 任何时候,你想开发一个新的 feature 、修改一个 bug 、甚至只是修复一个 typo 时, 67 | 都可以使用如下命令:: 68 | 69 | code start {branch-name} 70 | 71 | 会自动从最新的upstream/master创建分支。相当于:: 72 | 73 | git fetch upstream 74 | git checkout -b {branch} --no-track upstream/master 75 | 76 | 不用担心创建了太多 branch 发送 pullreq 时选择麻烦, codecli 为你提供了快速提交 77 | pullreq 的方法(见 `提交pull request`_ )。 78 | 79 | 与upstream/master同步 80 | ~~~~~~~~~~~~~~~~~~~~~ 81 | 82 | 当你的分支开发了一段时间,希望和上游其他人已经提交的改动合并,以便可以确保你的 83 | 改动在最新代码上也可正常工作时,你需要同步上游代码:: 84 | 85 | code sync 86 | 87 | 相当于:: 88 | 89 | git fetch upstream 90 | git merge upstream/master 91 | 92 | 可以用 ``--rebase`` 参数(缩写为 ``-r`` )指定执行 ``git rebase`` 而非 ``git 93 | merge`` 。 94 | 95 | 如果你的分支是从 ``code hotfix`` (见 `从非master分支进行hotfix`_ ) 创建的, 96 | 不用担心, codecli 会正确处理,不会不小心把 master merge 进来弄得一团糟。 97 | 98 | 提交pull request 99 | ~~~~~~~~~~~~~~~~ 100 | 101 | 当你的新 feature 或者 bugfix 已经完成,准备提交 pullreq 时(当然建议你先用 ``git 102 | rebase -i`` 清理一下提交,squash 无意义的 oops 或者 tmpsav 之类的 commits 先) 103 | ,在你的分支下执行如下命令:: 104 | 105 | code pr 106 | 107 | 会自动 merge master , push 到 origin ,然后打开浏览器直达创建 pull request 页 108 | 面。相当于:: 109 | 110 | code sync 111 | git push --set-upstream origin {branch} 112 | open http://code.dapps.douban.com/{upstream}/newpull/new?head_ref={branch}&base_ref=master 113 | 114 | 如果是 hotfix 分支, 也会设置正确的目标分支 (比如 ``release`` ) 115 | 116 | 加 ``-t`` 参数可以给其他人的 fork 提交 pull request,比如:: 117 | 118 | code pr -t satoru 119 | 120 | 此时,也可以用 ``user:branch`` 的形式,指定向其他人的指定 branch 提交 pull 121 | request,比如:: 122 | 123 | code pr -t satoru:zsh_completion 124 | 125 | 126 | 从非master分支进行hotfix 127 | ~~~~~~~~~~~~~~~~~~~~~~~~ 128 | 129 | 不少对稳定性有要求的项目在线上部署的不是 master 分支,而是其他分支(常见的是 130 | ``release`` 分支)。如果发现一个线上 bug 需要立刻修复,但此时 master 已经有了 131 | 一些新的改动,如果在 master 上修复然后 merge 到 release 上的话,可能解决了此问 132 | 题但又带来了新的问题。所以希望只上线针对紧急bug的改动。 133 | 134 | 这时你需要 codecli 的 hotfix 功能:: 135 | 136 | code hotfix {release-branch-name} {hotfix-name} 137 | 138 | 其中 {release-branch-name} 为线上 branch 名,例如:: 139 | 140 | code hotfix release ahbei-404 141 | 142 | 会从 upstream/{release-branch-name} 创建分支,起名为hotfix-{release-branch-name}-{hotfix-name} 。相当于:: 143 | 144 | git fetch upstream 145 | git checkout -b hotfix-release-ahbei-404 --no-track upstream/release 146 | 147 | 当执行 ``code pr`` 时,会自动将目标分支指向 {release-branch-name} 。 148 | 149 | 150 | checkout 到某个 pullreq 151 | ~~~~~~~~~~~~~~~~~~~~~~~ 152 | 153 | 在 review 某个 pullreq 时,有时我们希望能够在本地 checkout 改动的代码,以便在 154 | 本地执行单元测试、调试等工作。感谢 code 提供的 `使用refs拉取pr 155 | `_ 的功能 156 | ,可以用如下命令:: 157 | 158 | code pr {pr_id} 159 | 160 | 抓取指定 pullreq 并自动 checkout 到它的代码。 161 | 162 | 用 ``-t`` 参数可以 checkout 到某个用户的 fork 上的 pull request 。 163 | 164 | 在 checkout 到 pullreq 后,如果此 pullreq 还有后续提交,可以使用:: 165 | 166 | code sync 167 | 168 | 命令进行同步。并且还可以在本地编辑代码,提交。然后使用:: 169 | 170 | code pr 171 | 172 | 命令向此 pullreq 的发起仓库的对应分支发起 pullreq 。当发起人 merge 了你的 173 | pullreq 后,你提交的改动会自动出现在最初的 pullreq 中。 174 | 175 | 176 | fetch 其他人的 fork 177 | ~~~~~~~~~~~~~~~~~~~ 178 | 179 | 当合作开发一个项目时,可能其他人也有对 upstream 项目的 fork,有时你需要 180 | checkout 或者 merge 他的代码。手工用长长的 git url 加 remote 然后 fetch ?不用 181 | 那么麻烦,用 ``code fetch`` 轻松搞定:: 182 | 183 | code fetch {username} 184 | 185 | 即可自动创建一个新的 remote ,指向其他人的 fork ,并 fetch 之。相当于:: 186 | 187 | git remote add {username} http://code.dapps.douban.com/{username}/{repo}.git 188 | git fetch {username} 189 | 190 | 这要求其他人的 fork 遵循 code 的新的二级目录的结构(即 username/repo)。如果 191 | origin 也是一个 fork 的话,也需要遵循此结构。 192 | 193 | end 分支的开发 194 | ~~~~~~~~~~~~~~~~~~~ 195 | 196 | 当结束一个功能的开发时, 你可以用 ``code end`` 来搞定:: 197 | 198 | code sync 199 | code end {branchname} 200 | 201 | 即可自动删除远程和本地的branch, 结束这个功能的开发。相当于:: 202 | 203 | git br -d {branchname} 204 | git push origin :{branchname} 205 | 206 | branchname 缺省值为当前 branch ,所以用 ``code end`` 会直接删除当前的 branch。 207 | 208 | 如果需要同时删除多个 branch ,也可以用 ``code end br1 br2 br3`` 这种方式。 209 | 210 | 211 | 将 upstream 的一个分支 merge 到另一个分支 212 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 213 | 214 | 如果你维护的项目采用如 release 这样的分支标记正式上线版本和开发版本,并且用 215 | ``code hotfix`` 命令来给线上版本做 hotfix ,那么你可能会经常有这样两个需求: 216 | 217 | 1. 把 master 分支中的 commits 合并到 release 分支,准备上线。 218 | 2. 把做了 hotfix 的 release 分支中的改动合并到 master 分支中。 219 | 220 | 这时,你可以用 ``code merge`` 命令来简化操作。对第一种情况,执行:: 221 | 222 | code merge master release 223 | 224 | 会发起一个将 upstream 中的 master 分支合并到 release 分支的 pull request。对 225 | 第二种情况,执行:: 226 | 227 | code merge release master 228 | 229 | 则会发起一个从 release 到 master 的 pull request 。 230 | 231 | 使用 ``--push`` 参数可以在本地创建一个分支执行 merge 操作,然后直接 push 到 232 | upstream (需要有 upstream 的 push 权限)。如果有冲突,可以在本地修复冲突后, 233 | 重新用 ``--push`` 运行。 234 | 235 | 236 | 定制 webbrowser 的行为 237 | ~~~~~~~~~~~~~~~~~~~~~~ 238 | 239 | 在发送 pullreq 时,codecli 会使用默认浏览器打开 code 的提交界面。可以用以下命 240 | 令来定制此行为: 241 | 242 | code config webbrowser.name firefox 243 | 244 | 指定使用 Firefox 来打开。此处可选择的值为 Python 的 webbrowser_ 模块中注册的名字。 245 | 246 | .. _webbrowser: http://docs.python.org/2.7/library/webbrowser.html 247 | 248 | code config webbrowser.name /path/to/executable 249 | 250 | 使用指定脚本打开,待打开的 URL 会作为参数传递给脚本。 251 | 252 | code config webbrowser.name none 253 | 254 | 不使用浏览器打开,仅在终端显示URL地址。 255 | 256 | code config webbrowser.name --unset 257 | 258 | 恢复成使用默认浏览器打开。 259 | 260 | 261 | 让code与git命令结合更紧密 262 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 263 | 264 | 在使用codecli的时候,经常会出现一会使用code命令一会使用git命令的情况,为了让两个命令结合更紧密,你可以配置一下~/.gitconfig,参考配置如下:: 265 | 266 | [alias] 267 | start = !code start 268 | pr = !code pr 269 | sync = !code sync 270 | end = !code end 271 | 272 | zsh下的code命令补全 273 | ~~~~~~~~~~~~~~~~~~~ 274 | 275 | 将 ``_code`` 复制到 ``$fpath`` 中的某个目录,重启 zsh 就可以。 276 | 277 | 278 | ChangeLog 279 | ========= 280 | 281 | 2013-11-13 282 | 283 | * bugfix: 修复当 ``webbrowser.name`` 未设置或者设置为 script 时会抛异常的问题。 284 | Thank xupeng! 285 | 286 | 2013-11-08 287 | ~~~~~~~~~~ 288 | 289 | * feature: 增加 ``code config`` 命令,可以使用 ``code config webbrowser.name`` 290 | 定制 webbrowser 行为。 291 | * feature: 允许 ``code clone`` 使用URL作为参数。 Thank satoru! 292 | * feature: ``code fork`` 时默认使用自己的fork仓库名。 Thank satoru! 293 | * feature: ``code fork`` 时默认 clone 到仓库同名目录。 Thank satoru! 294 | * feature: 支持 code ssh url。 Thank chenzheng and yaofeng! 295 | * feature: 允许 ``code end`` 结束多个 branches。 Thank satoru! 296 | * bugfix: 修正当仓库名中含有 ``g`` ``i`` ``t`` 字符时会出错的问题。 Thank anrs! 297 | * bugfix: 修复判断分支是否已经 push 到 remote 的方法,避免误判。 Thank satoru! 298 | * bugfix: 修复重复开启 pr-on-pr 会出错的问题。 Thank menghan! 299 | 300 | 2013-07-11 301 | ~~~~~~~~~~ 302 | 303 | * 在首次发 pullreq 的 branch 上使用 rebase master 代替 merge master,减少无谓 304 | 的 merge commit 305 | 306 | 2013-07-11 307 | ~~~~~~~~~~ 308 | 309 | * docfix: 修正了 ``code fork --help`` 帮助信息中的样例仓库名 (thank satoru) 310 | 311 | * bugfix: ``code merge --push`` 没有执行 ``git fetch upstream`` ,导致 merge 312 | 的数据不是最新的 313 | 314 | 2013-06-26 315 | ~~~~~~~~~~ 316 | 317 | * ``code end`` 命令增加 ``-f`` 参数,可删除未 push 的分支 (thank guibog) 318 | 319 | 2013-06-18 320 | ~~~~~~~~~~ 321 | 322 | * 允许 remote 为 "用户名@" 的形式的 URL (thank guibog) 323 | 324 | 2013-06-13 325 | ~~~~~~~~~~ 326 | 327 | * bugfix: 在非 git repo 目录下运行 code 会出错 328 | 329 | 2013-06-09 330 | ~~~~~~~~~~ 331 | 332 | * ``code end`` 命令默认关闭当前分支 (thank guibog) 333 | 334 | 2013-06-04 335 | ~~~~~~~~~~ 336 | 337 | * 增加 ``code merge`` 命令,简化 release 分支的管理。 338 | 339 | 2013-05-20 340 | ~~~~~~~~~~ 341 | 342 | * ``code pr -t`` 参数支持指定目标仓库的 branch。 343 | 344 | 2013-04-01 345 | ~~~~~~~~~~ 346 | 347 | * ``code start`` 时如果目标 branch 已存在,会提示是要切换还是重建。 348 | 349 | 2013-03-26 350 | ~~~~~~~~~~ 351 | 352 | * 不使用 ``commands.getoutput`` ,以支持windows 353 | -------------------------------------------------------------------------------- /_code: -------------------------------------------------------------------------------- 1 | #compdef code 2 | 3 | _code() { 4 | local curcontext="$curcontext" state line 5 | typeset -A opt_args 6 | 7 | _arguments \ 8 | '1: :->subcommand'\ 9 | '*: :->arguments' 10 | 11 | case $state in 12 | subcommand) 13 | _arguments '1:subcommands:(`code | head -2 | ack -o "{.*}" | sed "s/[{},]/ /g"`)' 14 | ;; 15 | *) 16 | case $words[2] in 17 | end) 18 | compadd "$@" `git branch | tr -s ' ' | cut -d ' ' -f 2` 19 | ;; 20 | *) 21 | _files 22 | esac 23 | esac 24 | } 25 | 26 | _code "$@" 27 | -------------------------------------------------------------------------------- /codecli/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | import sys 4 | import logging 5 | 6 | from six import print_ 7 | 8 | 9 | def main(): 10 | from argparse import ArgumentParser 11 | 12 | parser = ArgumentParser() 13 | subparsers = parser.add_subparsers(title="commands", dest="subparser_command") 14 | subcommands = [ 15 | ('config', 'config', "Get and set codecli options"), 16 | ('fork', 'fork', "Create a fork"), 17 | ('start', 'start', "Start a new feature/bugfix branch"), 18 | ('sync', 'sync', "Sync branch with master"), 19 | ('pullreq', 'pullreq', "Send a pull request"), 20 | ('pr', 'pullreq', "Alias of pullreq"), 21 | ('hotfix', 'hotfix', "Make a hotfix for branch other than master"), 22 | ('clone', 'clone', "Clone a repository to local"), 23 | ('fetch', 'fetch', "Set remote and fetch other user's fork"), 24 | ('end', 'end', "Delete branch locally and on origin remote"), 25 | ('merge', 'merge', "Merge an upstream branch to another upstream branch"), 26 | ] 27 | 28 | for command, module_name, help_text in subcommands: 29 | try: 30 | module = __import__( 31 | 'codecli.commands.' + module_name, 32 | globals(), 33 | locals(), 34 | ['populate_argument_parser', 'main'], 35 | ) 36 | except ImportError: 37 | import traceback 38 | 39 | traceback.print_exc() 40 | print_("Can not import command %s, skip it" % command, file=sys.stderr) 41 | continue 42 | 43 | subparser = subparsers.add_parser(command, description=help_text) 44 | subparser.add_argument( 45 | '-v', '--verbose', action='store_true', help="enable additional output" 46 | ) 47 | 48 | module.populate_argument_parser(subparser) 49 | subparser.set_defaults(func=module.main) 50 | 51 | argv = sys.argv[1:] or ['--help'] 52 | args = parser.parse_args(argv) 53 | 54 | loglevel = logging.DEBUG if args.verbose else logging.INFO 55 | logging.basicConfig(level=loglevel) 56 | 57 | return args.func(args) 58 | -------------------------------------------------------------------------------- /codecli/apic.py: -------------------------------------------------------------------------------- 1 | """Code API client""" 2 | 3 | import json 4 | 5 | from six.moves.urllib.request import urlopen 6 | 7 | ENDPOINT = 'http://code.dapps.douban.com/api/' 8 | 9 | 10 | def get(path): 11 | f = urlopen('http://code.dapps.douban.com/api/' + path) 12 | return json.load(f) 13 | 14 | 15 | def get_pullinfo(repo, pr_id): 16 | return get('{0}/pull/{1}'.format(repo, pr_id)) 17 | -------------------------------------------------------------------------------- /codecli/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hongqn/codecli/2c33c525a6adca0f82f8e76df288d53a86dfc8bb/codecli/commands/__init__.py -------------------------------------------------------------------------------- /codecli/commands/clone.py: -------------------------------------------------------------------------------- 1 | from codecli.utils import ( 2 | check_call, 3 | repo_git_url, 4 | cd, 5 | merge_config, 6 | get_default_provider, 7 | ) 8 | 9 | 10 | def populate_argument_parser(parser): 11 | parser.add_argument('repo', help="url or name of repo [e.g. dae]") 12 | parser.add_argument('dir', nargs='?', help="directory to clone to") 13 | provider = get_default_provider() 14 | parser.add_argument( 15 | '-p', 16 | '--provider', 17 | default=provider, 18 | help="Git service provider code/github. [%s]" % provider, 19 | ) 20 | 21 | 22 | def main(args): 23 | url = repo_git_url(args.repo, provider=args.provider) 24 | cmd = ['git', 'clone', url] 25 | 26 | if args.dir: 27 | cmd.append(args.dir) 28 | dir = args.dir 29 | else: 30 | dir = url.rsplit('/', 1)[-1].rpartition('.git')[0] 31 | 32 | check_call(cmd) 33 | 34 | with cd(dir): 35 | merge_config() 36 | 37 | # set upstream to origin to make other code commands work 38 | check_call(['git', 'remote', 'add', 'upstream', url]) 39 | -------------------------------------------------------------------------------- /codecli/commands/config.py: -------------------------------------------------------------------------------- 1 | from codecli.utils import get_config, set_config, del_config 2 | 3 | CONFIG_KEYS = ['user.email', 'user.name', 'webbrowser.name'] 4 | 5 | 6 | def populate_argument_parser(parser): 7 | parser.add_argument('key', choices=CONFIG_KEYS) 8 | group = parser.add_mutually_exclusive_group() 9 | group.add_argument('value', nargs='?') 10 | group.add_argument('--unset', action='store_true') 11 | 12 | 13 | def main(args): 14 | if args.unset: 15 | del_config(args.key) 16 | elif args.value: 17 | set_config(args.key, args.value) 18 | else: 19 | print(get_config(args.key)) 20 | -------------------------------------------------------------------------------- /codecli/commands/end.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from codecli.utils import ( 4 | check_call, 5 | get_branches, 6 | get_current_branch_name, 7 | merge_with_base, 8 | log_error, 9 | ask, 10 | ) 11 | 12 | 13 | def populate_argument_parser(parser): 14 | parser.add_argument('branches', nargs='*', help="[default: current branch]") 15 | parser.add_argument( 16 | '-f', 17 | '--force', 18 | action='store_true', 19 | help="force branch deletion even if not " "fully merged (git branch -D)", 20 | ) 21 | 22 | 23 | def main(args): 24 | branches = args.branches 25 | if not branches: 26 | branches = [get_current_branch_name()] 27 | assert 'master' not in branches, "Cannot terminate master branch!" 28 | for br in branches: 29 | try: 30 | end_branch(br, args.force) 31 | except subprocess.CalledProcessError as e: 32 | print(e) 33 | log_error("Fail to delete branch %s." % br) 34 | 35 | 36 | def end_branch(branch, force): 37 | if branch == get_current_branch_name(): 38 | check_call(['git', 'checkout', 'master']) 39 | if force: 40 | check_call(['git', 'branch', '-D', branch]) 41 | else: 42 | try: 43 | check_call(['git', 'branch', '-d', branch]) 44 | except subprocess.CalledProcessError: 45 | log_error( 46 | "Failed to delete branch %s because it is not fully " 47 | "merged (may cause commits loss)." % branch 48 | ) 49 | answer = ask( 50 | "Do you want to force to delete it even so? (y/N) ", 51 | pattern=r'[nNyY].*', 52 | default='n', 53 | ) 54 | if answer[0] in 'yY': 55 | check_call(['git', 'branch', '-D', branch]) 56 | else: 57 | raise 58 | 59 | if does_branch_exist_on_origin(branch): 60 | check_call(['git', 'push', 'origin', ':%s' % branch]) 61 | 62 | 63 | def does_branch_exist_on_origin(branch): 64 | branches = get_branches(include_remotes=True) 65 | return 'remotes/origin/{0}'.format(branch) in branches 66 | -------------------------------------------------------------------------------- /codecli/commands/fetch.py: -------------------------------------------------------------------------------- 1 | from codecli.utils import check_call, repo_git_url, getoutput 2 | 3 | 4 | def populate_argument_parser(parser): 5 | parser.add_argument('username', help="username of another fork of this repo") 6 | 7 | 8 | def main(args): 9 | add_remote(args.username) 10 | fetch(args.username) 11 | 12 | 13 | def add_remote(username): 14 | user_git_url = origin_git_url = None 15 | 16 | output = getoutput(['git', 'remote', '-v']) 17 | for line in output.splitlines(): 18 | words = line.split() 19 | if words[0] == 'origin': 20 | origin_git_url = words[1] 21 | if words[0] == username: 22 | user_git_url = words[1] 23 | 24 | if not origin_git_url: 25 | raise Exception("No origin remote found, abort") 26 | 27 | repo = origin_git_url.rsplit('/', 1)[-1].rsplit('.', 1)[0] 28 | remote_name = username 29 | remote_url = repo_git_url('%s/%s' % (username, repo)) 30 | 31 | if user_git_url: 32 | if user_git_url != remote_url: 33 | raise Exception("remote %s already exists, delete it first?") 34 | # already added 35 | return 36 | 37 | check_call(['git', 'remote', 'add', remote_name, remote_url]) 38 | 39 | 40 | def fetch(remote_name): 41 | check_call(['git', 'fetch', remote_name]) 42 | -------------------------------------------------------------------------------- /codecli/commands/fork.py: -------------------------------------------------------------------------------- 1 | from codecli.utils import ( 2 | check_call, 3 | repo_git_url, 4 | cd, 5 | merge_config, 6 | print_log, 7 | get_code_username, 8 | get_default_provider, 9 | log_error, 10 | ) 11 | 12 | 13 | def populate_argument_parser(parser): 14 | parser.add_argument('upstream', help="name of upstream repo [e.g. dae]") 15 | code_username = get_code_username() 16 | if code_username: 17 | parser.add_argument( 18 | 'origin', 19 | nargs='?', 20 | help="name of my fork [e.g. hongqn/dae] " 21 | "[default %s/UPSTREAM]" % code_username, 22 | ) 23 | else: 24 | parser.add_argument('origin', help="name of my fork [e.g. hongqn/dae]") 25 | parser.add_argument('dir', nargs='?', help="directory to clone") 26 | provider = get_default_provider() 27 | parser.add_argument( 28 | '-p', 29 | '--provider', 30 | default=provider, 31 | help="Git service provider code/github. [%s]" % provider, 32 | ) 33 | 34 | 35 | def main(args): 36 | name = args.upstream 37 | 38 | code_username = '' 39 | if not args.origin: 40 | code_username = get_code_username() 41 | if not code_username: 42 | log_error('origin not specified') 43 | return 1 44 | repo_name = name.split('/')[-1] 45 | args.origin = '%s/%s' % (code_username, repo_name) 46 | 47 | if not args.dir: 48 | args.dir = name.rsplit('/')[-1] 49 | print_log("Destination dir is not specified, will use {}".format(args.dir)) 50 | 51 | check_call( 52 | [ 53 | 'git', 54 | 'clone', 55 | repo_git_url(args.origin, login_user=code_username, provider=args.provider), 56 | args.dir, 57 | ] 58 | ) 59 | with cd(args.dir): 60 | merge_config() 61 | check_call(['git', 'remote', 'add', 'upstream', repo_git_url(name)]) 62 | -------------------------------------------------------------------------------- /codecli/commands/hotfix.py: -------------------------------------------------------------------------------- 1 | from codecli.utils import check_call 2 | 3 | 4 | def populate_argument_parser(parser): 5 | parser.add_argument( 6 | 'start_point', 7 | default='release', 8 | help="branch to start hotfix from [default: %(default)s]", 9 | ) 10 | parser.add_argument('issue', help="a short name for the hotfix") 11 | 12 | 13 | def main(args): 14 | branch_name = 'hotfix-%s-%s' % (args.start_point, args.issue) 15 | check_call(['git', 'fetch', 'upstream']) 16 | check_call( 17 | [ 18 | 'git', 19 | 'checkout', 20 | '-b', 21 | branch_name, 22 | '--no-track', 23 | 'upstream/' + args.start_point, 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /codecli/commands/merge.py: -------------------------------------------------------------------------------- 1 | from codecli.utils import ( 2 | check_call, 3 | send_pullreq, 4 | get_remote_repo_name, 5 | get_branches, 6 | ask, 7 | ) 8 | 9 | 10 | def populate_argument_parser(parser): 11 | parser.add_argument('from_branch', help="upstream branch name") 12 | parser.add_argument('to_branch', help="upstream branch name") 13 | parser.add_argument( 14 | '--push', 15 | action='store_true', 16 | help="push to upstream if successfully merged " "[default: send pull request]", 17 | ) 18 | 19 | 20 | def main(args): 21 | if args.push: 22 | return merge_and_push(args.from_branch, args.to_branch) 23 | 24 | else: 25 | return send_merge_pullreq(args.from_branch, args.to_branch) 26 | 27 | 28 | def merge_and_push(from_branch, to_branch): 29 | check_call(['git', 'fetch', 'upstream']) 30 | local_branch = 'merge/{0}-to-{1}'.format(from_branch, to_branch) 31 | existing_branches = get_branches() 32 | answer = 'd' 33 | if local_branch in existing_branches: 34 | answer = ask( 35 | "Branch {0} exists. Should I (d)estroy it and " 36 | "re-merge from scratch, or re(u)se it in case you " 37 | "were resolving merge conflicts just now? (D/u) ".format(local_branch), 38 | pattern=r'[dDuU]', 39 | default='d', 40 | ).lower()[0] 41 | 42 | if answer == 'd': 43 | check_call( 44 | ['git', 'checkout', '-B', local_branch, 'upstream/{0}'.format(to_branch)] 45 | ) 46 | 47 | else: 48 | check_call(['git', 'checkout', local_branch]) 49 | 50 | check_call(['git', 'merge', 'upstream/{0}'.format(from_branch)]) 51 | check_call(['git', 'push', 'upstream', '{0}:{1}'.format(local_branch, to_branch)]) 52 | check_call(['git', 'checkout', 'master']) 53 | check_call(['git', 'branch', '-d', local_branch]) 54 | 55 | 56 | def send_merge_pullreq(from_branch, to_branch): 57 | repo = get_remote_repo_name('upstream') 58 | send_pullreq(repo, from_branch, repo, to_branch) 59 | -------------------------------------------------------------------------------- /codecli/commands/pullreq.py: -------------------------------------------------------------------------------- 1 | import codecli.commands.fetch 2 | from codecli.commands.start import start 3 | from codecli.utils import ( 4 | check_call, 5 | get_base_branch, 6 | get_current_branch_name, 7 | get_master_branch_name, 8 | get_pullinfo, 9 | get_remote_repo_name, 10 | getoutput, 11 | merge_with_base, 12 | print_log, 13 | remote_and_pr_id_from_pr_branch, 14 | ) 15 | from codecli.utils import send_pullreq as _send_pullreq 16 | 17 | 18 | def populate_argument_parser(parser): 19 | parser.add_argument( 20 | "pr_id", 21 | nargs="?", 22 | help="fetch and switch to a specific pullreq " 23 | "(default: submit a new pullreq)", 24 | ) 25 | parser.add_argument( 26 | "-t", 27 | "--target", 28 | metavar="USER", 29 | help="act on a user's fork, or use the format of " 30 | "USER:BRANCH to specify the target branch name", 31 | ) 32 | parser.add_argument( 33 | "-n", 34 | "--nomerge", 35 | action="store_true", 36 | help="submit pullreq without merge with upstream", 37 | ) 38 | 39 | 40 | def main(args): 41 | if args.target: 42 | remote, remote_branch = get_remote_and_remote_branch_from_target(args.target) 43 | codecli.commands.fetch.add_remote(remote) 44 | else: 45 | remote, remote_branch = "upstream", None 46 | 47 | if args.pr_id: 48 | return fetch_and_switch_to_pr(args.pr_id, remote=remote) 49 | else: 50 | return submit_new_pullreq( 51 | remote=remote, remote_branch=remote_branch, no_merge=args.nomerge 52 | ) 53 | 54 | 55 | def fetch_and_switch_to_pr(pr_id, remote="upstream"): 56 | ref = "{0}/pr/{1}".format(remote, pr_id) 57 | branch = ( 58 | "pr/{0}".format(pr_id) 59 | if remote == "upstream" 60 | else "pr/{0}/{1}".format(remote, pr_id) 61 | ) 62 | fetch_args = ["+refs/pull/{0}/head:refs/remotes/{1}".format(pr_id, ref)] 63 | start(branch, remote=remote, fetch_args=fetch_args, base_ref=ref) 64 | 65 | 66 | def get_remote_and_remote_branch_from_target(target): 67 | segs = target.split(":") 68 | remote = segs[0] 69 | remote_branch = segs[1] if len(segs) >= 2 else None 70 | return remote, remote_branch 71 | 72 | 73 | def submit_new_pullreq(remote="upstream", remote_branch=None, no_merge=False): 74 | branch = get_current_branch_name() 75 | master = get_master_branch_name() 76 | if branch == master: 77 | print_log(f"Pull request should never be from {master}") 78 | return 1 79 | 80 | if not no_merge: 81 | # rebase = not branch_is_published_already(branch) 82 | rebase = True 83 | merge_with_base( 84 | branch, rebase=rebase, remote=remote, remote_branch=remote_branch 85 | ) 86 | push_to_my_fork(branch) 87 | send_pullreq(branch, remote=remote, remote_branch=remote_branch) 88 | 89 | 90 | def push_to_my_fork(branch): 91 | check_call(["git", "push", "--set-upstream", "origin", branch]) 92 | 93 | 94 | def branch_is_published_already(branch): 95 | check_call(["git", "fetch", "origin"]) 96 | master = get_master_branch_name() 97 | commits = getoutput(["git", "log", "--pretty=oneline", f"{master}..{branch}"]) 98 | if commits: 99 | log_line = commits.split("\n")[-1] 100 | first_ci = log_line.split()[0] 101 | else: 102 | first_ci = branch 103 | return bool(getoutput(["git", "branch", "-r", "--contains", first_ci])) 104 | 105 | 106 | def send_pullreq(branch, remote="upstream", remote_branch=None): 107 | remote, fetch_args, baseref = get_base_branch( 108 | branch, remote=remote, remote_branch=remote_branch 109 | ) 110 | base_repo = get_remote_repo_name(remote) 111 | 112 | if branch.startswith("pr/") and remote_branch is None: 113 | # for pr-on-pr 114 | remote, pr_id = remote_and_pr_id_from_pr_branch(branch) 115 | base_repo, baseref = get_pullinfo(get_remote_repo_name(remote), pr_id) 116 | 117 | _send_pullreq(get_remote_repo_name("origin"), branch, base_repo, baseref) 118 | -------------------------------------------------------------------------------- /codecli/commands/start.py: -------------------------------------------------------------------------------- 1 | from codecli.commands.end import end_branch 2 | from codecli.utils import ask, check_call, get_branches, get_master_branch_name 3 | 4 | 5 | def populate_argument_parser(parser): 6 | parser.add_argument('feature') 7 | 8 | 9 | def main(args): 10 | branch = args.feature 11 | start(branch) 12 | 13 | 14 | def start(branch, remote='upstream', fetch_args=[], base_ref=None): 15 | if base_ref is None: 16 | master = get_master_branch_name() 17 | base_ref = f'upstream/{master}' 18 | existing_branches = get_branches() 19 | if branch in existing_branches: 20 | answer = ask( 21 | "Branch %s exists, (s)witch to it or re(c)reate " "it? (S/c) " % branch, 22 | pattern=r'[sScC]', 23 | default='s', 24 | ) 25 | answer = answer.lower()[0] 26 | 27 | if answer == 's': 28 | check_call(['git', 'checkout', branch]) 29 | return 30 | 31 | elif answer == 'c': 32 | end_branch(branch, force=True) 33 | 34 | check_call(['git', 'fetch', remote] + fetch_args) 35 | check_call(['git', 'checkout', '-b', branch, '--no-track', base_ref]) 36 | -------------------------------------------------------------------------------- /codecli/commands/sync.py: -------------------------------------------------------------------------------- 1 | from codecli.utils import get_current_branch_name, merge_with_base 2 | 3 | 4 | def populate_argument_parser(parser): 5 | parser.add_argument( 6 | '-r', '--rebase', action='store_true', help="rebase with upstream" 7 | ) 8 | parser.add_argument('-b', '--base', help="Branch to rebase on") 9 | parser.add_argument('-R', '--remote', default="upstream", help="Remote to fetch") 10 | 11 | 12 | def main(args): 13 | branch = get_current_branch_name() 14 | merge_with_base( 15 | branch, rebase=args.rebase, remote_branch=args.base, remote=args.remote 16 | ) 17 | -------------------------------------------------------------------------------- /codecli/providers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import re 5 | from codecli.providers.base import KNOWN_PROVIDERS 6 | 7 | _instance = None 8 | 9 | 10 | class NoProviderFound(Exception): 11 | pass 12 | 13 | 14 | def current_repo_git_url(remote): 15 | from codecli.utils import getoutput, is_under_git_repo 16 | 17 | if not is_under_git_repo(os.path.curdir): 18 | raise NoProviderFound("It is not under a git repo") 19 | 20 | for line in getoutput(['git', 'remote', '-v']).splitlines(): 21 | words = line.split() 22 | if words[0] == remote and words[-1] == '(push)': 23 | giturl = words[1] 24 | break 25 | else: 26 | raise NoProviderFound("no remote %s found" % remote) 27 | return re.sub(r"(?<=http://).+:.+@", "", giturl) 28 | 29 | 30 | def get_git_service_provider(force_provider=None): 31 | global _instance 32 | 33 | if force_provider is not None: 34 | 35 | def chooser(url): 36 | return force_provider in url 37 | 38 | else: 39 | 40 | def chooser(url): 41 | return url in current_repo_git_url('origin') 42 | 43 | if _instance is None: 44 | for url, sub_class in KNOWN_PROVIDERS.items(): 45 | if chooser(url): 46 | _instance = sub_class() 47 | break 48 | else: 49 | raise TypeError("Not supported git provider") 50 | return _instance 51 | -------------------------------------------------------------------------------- /codecli/providers/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import glob 5 | import importlib 6 | 7 | KNOWN_PROVIDERS = {} 8 | 9 | 10 | class ProviderMeta(type): 11 | def __new__(cls, name, bases, attrs): 12 | super_new = super(ProviderMeta, cls).__new__ 13 | new_class = super_new(cls, name, bases, attrs) 14 | for url in new_class.URLS: 15 | KNOWN_PROVIDERS[url] = new_class 16 | return new_class 17 | 18 | 19 | class GitServiceProvider(object): 20 | URLS = [] 21 | 22 | def send_pullreq(self, head_repo, head_ref, base_repo, base_ref): 23 | raise NotImplementedError 24 | 25 | def get_remote_repo_name(self, remote): 26 | raise NotImplementedError 27 | 28 | def get_remote_repo_url(self, remote): 29 | raise NotImplementedError 30 | 31 | def get_repo_git_url(self, repo_name, login_user=''): 32 | raise NotImplementedError 33 | 34 | def get_username(self): 35 | raise NotImplementedError 36 | 37 | def merge_config(self): 38 | raise NotImplementedError 39 | 40 | def get_pullinfo(self, repo, pr_id): 41 | raise NotImplementedError 42 | 43 | 44 | GitServiceProvider = ProviderMeta('GitServiceProvider', (GitServiceProvider,), {}) 45 | 46 | 47 | providers = glob.glob( 48 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "provider_*.py") 49 | ) 50 | 51 | for each in providers: 52 | import_path = ".." + os.path.basename(each)[: -len('.py')] 53 | importlib.import_module(import_path, __name__) 54 | -------------------------------------------------------------------------------- /codecli/providers/provider_code.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | from getpass import getuser 5 | import json 6 | 7 | from six.moves.urllib.parse import urlencode 8 | from six.moves.urllib.request import urlopen 9 | 10 | import codecli.utils as utils 11 | from codecli.providers.base import GitServiceProvider 12 | 13 | 14 | class CodeProvider(GitServiceProvider): 15 | URLS = ['code.dapps.douban.com', 'code.intra.douban.com'] 16 | 17 | def send_pullreq(self, head_repo, head_ref, base_repo, base_ref): 18 | 19 | url = ('http://code.dapps.douban.com/%s/newpull/new?' % head_repo) + urlencode( 20 | dict(head_ref=head_ref, base_ref=base_ref, base_repo=base_repo) 21 | ) 22 | utils.print_log("goto " + url) 23 | utils.browser_open(url) 24 | 25 | def get_remote_repo_name(self, remote): 26 | repourl = self.get_remote_repo_url(remote) 27 | _, _, reponame = repourl.partition('code.dapps.douban.com/') 28 | if not reponame: 29 | _, _, reponame = repourl.partition('code.intra.douban.com:') 30 | if not reponame: 31 | _, _, reponame = repourl.partition('code.dapps.douban.com:') 32 | return reponame 33 | 34 | def get_remote_repo_url(self, remote): 35 | for line in utils.getoutput(['git', 'remote', '-v']).splitlines(): 36 | words = line.split() 37 | if words[0] == remote and words[-1] == '(push)': 38 | giturl = words[1] 39 | break 40 | else: 41 | raise Exception("no remote %s found" % remote) 42 | 43 | giturl = re.sub(r"(?<=http://).+:.+@", "", giturl) 44 | assert re.match( 45 | r"^http://([a-zA-Z0-9]+@)?code.dapps.douban.com/.+\.git$", giturl 46 | ) or re.match(r"^git@code.(intra|dapps).douban.com:.+\.git$", giturl), ( 47 | "This url do not look like code dapps git repo url: %s" % giturl 48 | ) 49 | repourl = giturl[: -len('.git')] 50 | return repourl 51 | 52 | def get_repo_git_url(self, repo_name, login_user=''): 53 | if '://' in repo_name: 54 | return repo_name 55 | 56 | if login_user: 57 | login_user = login_user + '@' 58 | CODE_ADDR = 'code.dapps.douban.com' 59 | return 'http://%s%s/%s.git' % (login_user, CODE_ADDR, repo_name) 60 | 61 | def get_username(self): 62 | email = utils.get_config('user.email') 63 | return email.split('@')[0] if email and email.endswith('@douban.com') else None 64 | 65 | def merge_config(self): 66 | email = utils.get_config('user.email') 67 | if not email: 68 | email = utils.getoutput(['git', 'config', 'user.email']).strip() 69 | if not email.endswith('@douban.com'): 70 | email = '%s@douban.com' % getuser() 71 | email = utils.ask( 72 | "Please enter your @douban.com email [%s]: " % email, default=email 73 | ) 74 | utils.set_config('user.email', email) 75 | 76 | name = utils.get_user_name() 77 | if not name: 78 | name = email.split('@')[0] 79 | name = utils.ask("Please enter your name [%s]: " % name, default=name) 80 | utils.set_config('user.name', name) 81 | 82 | for key, value in utils.iter_config(): 83 | utils.check_call(['git', 'config', key, value]) 84 | 85 | def get_pullinfo(self, repo, pr_id): 86 | url = 'http://code.dapps.douban.com/api/{0}/pull/{1}'.format(repo, pr_id) 87 | f = urlopen(url) 88 | data = json.load(f) 89 | return data['head']['repo']['name'], data['head']['ref'] 90 | -------------------------------------------------------------------------------- /codecli/providers/provider_gitein.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | import json 5 | from getpass import getuser 6 | 7 | from six.moves.urllib.parse import urlencode 8 | from six.moves.urllib.request import urlopen 9 | 10 | import codecli.utils as utils 11 | from codecli.providers.base import GitServiceProvider 12 | 13 | 14 | class GitEinProvider(GitServiceProvider): 15 | # FIXME allow to customize domain in config 16 | URLS = ['git.ein.plus'] 17 | 18 | def send_pullreq(self, head_repo, head_ref, base_repo, base_ref): 19 | head_user, _ = head_repo.split("/") 20 | base_user, _ = base_repo.split("/") 21 | 22 | url = "https://git.ein.plus/%s/-/merge_requests/new?" % head_repo + urlencode( 23 | { 24 | 'merge_request[source_branch]': head_ref, 25 | 'merge_request[source_project]': head_user, 26 | 'merge_request[target_branch]': base_ref, 27 | 'merge_request[target_project]': base_user, 28 | } 29 | ) 30 | 31 | utils.print_log("goto " + url) 32 | utils.browser_open(url) 33 | 34 | def get_remote_repo_name(self, remote): 35 | repourl = self.get_remote_repo_url(remote) 36 | _, _, reponame = repourl.partition('git.ein.plus/') 37 | if not reponame: 38 | _, _, reponame = repourl.partition('git.ein.plus:') 39 | return reponame 40 | 41 | def get_remote_repo_url(self, remote): 42 | for line in utils.getoutput(['git', 'remote', '-v']).splitlines(): 43 | words = line.split() 44 | if words[0] == remote and words[-1] == '(push)': 45 | giturl = words[1] 46 | break 47 | else: 48 | raise Exception("no remote %s found" % remote) 49 | 50 | giturl = re.sub(r"(?<=http://).+:.+@", "", giturl) 51 | 52 | assert re.match(r"^git@git.ein.plus:.+\.git$", giturl) or re.match( 53 | r"^https://git.ein.plus/.+\.git$", giturl 54 | ), ("This url do not look like a git.ein.plus repo url: %s" % giturl) 55 | repourl = giturl[: -len('.git')] 56 | return repourl 57 | 58 | def get_repo_git_url(self, repo_name, login_user=''): 59 | if '://' in repo_name: 60 | return repo_name 61 | 62 | return 'https://git.ein.plus/%s.git' % repo_name 63 | 64 | def search_username(self): 65 | email = utils.get_user_email() 66 | payload = json.load( 67 | urlopen("https://api.git.ein.plus/search/users?" + urlencode(dict(q=email))) 68 | ) 69 | return payload['items'][0]['login'] if payload['total_count'] else '' 70 | 71 | def get_username(self): 72 | return utils.get_config('user.name') or utils.get_user_name() 73 | 74 | def merge_config(self): 75 | email = utils.get_config('user.email') 76 | if not email: 77 | email = utils.getoutput(['git', 'config', 'user.email']).strip() 78 | if not email.endswith('@ein.plus'): 79 | email = '%s@ein.plus' % getuser() 80 | email = utils.ask( 81 | "Please enter your @ein.plus email [%s]: " % email, default=email 82 | ) 83 | utils.set_config('user.email', email) 84 | 85 | name = utils.get_user_name() 86 | if not name: 87 | name = email.split('@')[0] 88 | name = utils.ask("Please enter your name [%s]: " % name, default=name) 89 | utils.set_config('user.name', name) 90 | 91 | for key, value in utils.iter_config(): 92 | utils.check_call(['git', 'config', key, value]) 93 | -------------------------------------------------------------------------------- /codecli/providers/provider_github.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | import json 5 | 6 | from six.moves.urllib.parse import urlencode, quote 7 | from six.moves.urllib.request import urlopen 8 | 9 | import codecli.utils as utils 10 | from codecli.providers.base import GitServiceProvider 11 | 12 | 13 | class GithubProvider(GitServiceProvider): 14 | URLS = ['github.com'] 15 | 16 | def send_pullreq(self, head_repo, head_ref, base_repo, base_ref): 17 | head_user, _ = head_repo.split("/") 18 | base_user, _ = base_repo.split("/") 19 | 20 | url = "https://github.com/%s/compare/%s:%s...%s:%s?expand=1" % ( 21 | head_repo, 22 | base_user, 23 | quote(base_ref), 24 | head_user, 25 | quote(head_ref), 26 | ) 27 | 28 | utils.print_log("goto " + url) 29 | utils.browser_open(url) 30 | 31 | def get_remote_repo_name(self, remote): 32 | repourl = self.get_remote_repo_url(remote) 33 | _, _, reponame = repourl.partition('github.com/') 34 | if not reponame: 35 | _, _, reponame = repourl.partition('github.com:') 36 | return reponame 37 | 38 | def get_remote_repo_url(self, remote): 39 | for line in utils.getoutput(['git', 'remote', '-v']).splitlines(): 40 | words = line.split() 41 | if words[0] == remote and words[-1] == '(push)': 42 | giturl = words[1] 43 | break 44 | else: 45 | raise Exception("no remote %s found" % remote) 46 | 47 | giturl = re.sub(r"(?<=http://).+:.+@", "", giturl) 48 | 49 | assert re.match(r"^git@github.com:.+\.git$", giturl) or re.match( 50 | r"^https://github.com/.+\.git$", giturl 51 | ), ("This url do not look like a github repo url: %s" % giturl) 52 | repourl = giturl[: -len('.git')] 53 | return repourl 54 | 55 | def get_repo_git_url(self, repo_name, login_user=''): 56 | if '://' in repo_name: 57 | return repo_name 58 | 59 | return 'https://github.com/%s.git' % repo_name 60 | 61 | def search_username(self): 62 | email = utils.get_user_email() 63 | payload = json.load( 64 | urlopen("https://api.github.com/search/users?" + urlencode(dict(q=email))) 65 | ) 66 | return payload['items'][0]['login'] if payload['total_count'] else '' 67 | 68 | def get_username(self): 69 | return utils.get_config('user.name') or utils.get_user_name() 70 | 71 | def merge_config(self): 72 | user_name = utils.get_config('user.name') 73 | if not user_name: 74 | user_name = self.search_username() 75 | if user_name: 76 | utils.set_config('user.name', user_name) 77 | -------------------------------------------------------------------------------- /codecli/providers/provider_gityashi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import re 5 | from getpass import getuser 6 | 7 | from six.moves.urllib.parse import urlencode 8 | from six.moves.urllib.request import urlopen 9 | 10 | import codecli.utils as utils 11 | from codecli.providers.base import GitServiceProvider 12 | 13 | 14 | class GitEinProvider(GitServiceProvider): 15 | # FIXME allow to customize domain in config 16 | URLS = ['git.ysdev.xyz'] 17 | 18 | def send_pullreq(self, head_repo, head_ref, base_repo, base_ref): 19 | head_user, _ = head_repo.split("/") 20 | base_user, _ = base_repo.split("/") 21 | 22 | url = "https://git.ysdev.xyz/%s/-/merge_requests/new?" % head_repo + urlencode( 23 | { 24 | 'merge_request[source_branch]': head_ref, 25 | 'merge_request[source_project]': head_user, 26 | 'merge_request[target_branch]': base_ref, 27 | 'merge_request[target_project]': base_user, 28 | } 29 | ) 30 | 31 | utils.print_log("goto " + url) 32 | utils.browser_open(url) 33 | 34 | def get_remote_repo_name(self, remote): 35 | repourl = self.get_remote_repo_url(remote) 36 | _, _, reponame = repourl.partition('git.ysdev.xyz/') 37 | if not reponame: 38 | _, _, reponame = repourl.partition('git.ysdev.xyz:') 39 | return reponame 40 | 41 | def get_remote_repo_url(self, remote): 42 | for line in utils.getoutput(['git', 'remote', '-v']).splitlines(): 43 | words = line.split() 44 | if words[0] == remote and words[-1] == '(push)': 45 | giturl = words[1] 46 | break 47 | else: 48 | raise Exception("no remote %s found" % remote) 49 | 50 | giturl = re.sub(r"(?<=http://).+:.+@", "", giturl) 51 | 52 | assert re.match(r"^git@git.ysdev.xyz:.+\.git$", giturl) or re.match( 53 | r"^https://git.ysdev.xyz/.+\.git$", giturl 54 | ), ("This url do not look like a git.ysdev.xyz repo url: %s" % giturl) 55 | repourl = giturl[: -len('.git')] 56 | return repourl 57 | 58 | def get_repo_git_url(self, repo_name, login_user=''): 59 | if '://' in repo_name: 60 | return repo_name 61 | 62 | return 'https://git.ysdev.xyz/%s.git' % repo_name 63 | 64 | def search_username(self): 65 | email = utils.get_user_email() 66 | payload = json.load( 67 | urlopen( 68 | "https://api.git.ysdev.xyz/search/users?" + urlencode(dict(q=email)) 69 | ) 70 | ) 71 | return payload['items'][0]['login'] if payload['total_count'] else '' 72 | 73 | def get_username(self): 74 | return utils.get_config('user.name') or utils.get_user_name() 75 | 76 | def merge_config(self): 77 | email = utils.get_config('user.email') 78 | if not email: 79 | email = utils.getoutput(['git', 'config', 'user.email']).strip() 80 | if not email.endswith('@yashihq.com'): 81 | email = '%s@yashihq.com' % getuser() 82 | email = utils.ask( 83 | "Please enter your @yashihq.com email [%s]: " % email, default=email 84 | ) 85 | utils.set_config('user.email', email) 86 | 87 | name = utils.get_user_name() 88 | if not name: 89 | name = email.split('@')[0] 90 | name = utils.ask("Please enter your name [%s]: " % name, default=name) 91 | utils.set_config('user.name', name) 92 | 93 | for key, value in utils.iter_config(): 94 | utils.check_call(['git', 'config', key, value]) 95 | -------------------------------------------------------------------------------- /codecli/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import webbrowser 5 | from contextlib import contextmanager 6 | from subprocess import PIPE, Popen 7 | from subprocess import call as _call 8 | from subprocess import check_call as _check_call 9 | 10 | from six import print_, string_types 11 | from six.moves import input 12 | 13 | 14 | def get_current_branch_name(): 15 | output = getoutput(['git', 'symbolic-ref', 'HEAD']) 16 | assert output.startswith('refs/heads/') 17 | return output[len('refs/heads/') :] 18 | 19 | 20 | def get_master_branch_name(): 21 | output = ( 22 | getoutput( 23 | ['git', 'branch', '-l', 'master', 'main', '--format', '%(refname:short)'] 24 | ) 25 | .strip() 26 | .lstrip('*') 27 | .strip() 28 | ) 29 | return output.split('/')[-1] 30 | 31 | 32 | def remote_and_pr_id_from_pr_branch(branch): 33 | assert branch.startswith('pr/') 34 | words = branch.split('/', 2) 35 | if len(words) == 2: 36 | remote, pr_id = 'upstream', words[1] 37 | else: 38 | remote, pr_id = words[1], words[2] 39 | return remote, pr_id 40 | 41 | 42 | def get_base_branch(branch, remote='upstream', remote_branch=None): 43 | if branch.startswith('hotfix-'): 44 | base_branch = branch.split('-')[1] 45 | return remote, [], base_branch 46 | 47 | if branch.startswith('pr/') and remote_branch is None: 48 | remote, pr_id = remote_and_pr_id_from_pr_branch(branch) 49 | ref = 'pr/{1}'.format(remote, pr_id) 50 | fetch_args = [ 51 | '+refs/pull/{0}/head:refs/remotes/{1}/{2}'.format(pr_id, remote, ref) 52 | ] 53 | return remote, fetch_args, ref 54 | 55 | return remote, [], remote_branch or get_master_branch_name() 56 | 57 | 58 | def merge_with_base(branch, rebase=False, remote='upstream', remote_branch=None): 59 | remote, fetch_args, baseref = get_base_branch( 60 | branch, remote=remote, remote_branch=remote_branch 61 | ) 62 | check_call(['git', 'fetch', remote] + fetch_args) 63 | check_call(['git', 'rebase' if rebase else 'merge', '%s/%s' % (remote, baseref)]) 64 | 65 | 66 | def check_call(cmd, *args, **kwargs): 67 | cmdstr = cmd if isinstance(cmd, string_types) else ' '.join(cmd) 68 | print_log(cmdstr) 69 | return _check_call(cmd, *args, **kwargs) 70 | 71 | 72 | def call(cmd, *args, **kwargs): 73 | cmdstr = cmd if isinstance(cmd, string_types) else ' '.join(cmd) 74 | print_log(cmdstr) 75 | return _call(cmd, *args, **kwargs) 76 | 77 | 78 | def print_log(outstr): 79 | print_(green(outstr), file=sys.stderr) 80 | 81 | 82 | def log_error(msg): 83 | print_(red(msg), file=sys.stderr) 84 | 85 | 86 | def repo_git_url(repo_name, login_user='', provider=None): 87 | from codecli.providers import get_git_service_provider 88 | 89 | return get_git_service_provider(force_provider=provider).get_repo_git_url( 90 | repo_name, login_user 91 | ) 92 | 93 | 94 | @contextmanager 95 | def cd(path): 96 | cwd = os.getcwd() 97 | os.chdir(path) 98 | try: 99 | yield cwd 100 | finally: 101 | os.chdir(cwd) 102 | 103 | 104 | def ask(prompt, pattern=r'.*', default=''): 105 | while True: 106 | answer = input(green(prompt)) 107 | 108 | if not answer and default: 109 | return default 110 | 111 | if re.match(pattern, answer): 112 | return answer 113 | 114 | 115 | def get_config_path(): 116 | return os.path.expanduser('~/.codecli.conf') 117 | 118 | 119 | def get_config(key): 120 | return getoutput(['git', 'config', '-f', get_config_path(), key]).strip() 121 | 122 | 123 | def set_config(key, value): 124 | check_call(['git', 'config', '-f', get_config_path(), key, value]) 125 | 126 | 127 | def del_config(key): 128 | check_call(['git', 'config', '-f', get_config_path(), '--unset', key]) 129 | 130 | 131 | def iter_config(): 132 | for line in getoutput( 133 | ['git', 'config', '-f', get_config_path(), '--list'] 134 | ).splitlines(): 135 | line = line.strip() 136 | if not line: 137 | continue 138 | 139 | key, value = line.split('=', 1) 140 | yield key, value 141 | 142 | 143 | def merge_config(): 144 | """merge all config in ~/.codecli.conf to current git repo's config. 145 | 146 | Will prompt for email and name if they do not exist in ~/.codecli.conf. 147 | 148 | """ 149 | from codecli.providers import get_git_service_provider 150 | 151 | return get_git_service_provider().merge_config() 152 | 153 | 154 | def get_user_name(): 155 | name = get_config('user.name') 156 | if not name: 157 | name = getoutput(['git', 'config', 'user.name']).strip() 158 | return name 159 | 160 | 161 | def get_user_email(): 162 | name = get_config('user.email') 163 | if not name: 164 | name = getoutput(['git', 'config', 'user.email']).strip() 165 | return name 166 | 167 | 168 | def get_code_username(): 169 | user_name = get_config('user.name') 170 | if not user_name: 171 | from codecli.providers import NoProviderFound, get_git_service_provider 172 | 173 | try: 174 | user_name = get_git_service_provider().get_username() 175 | except NoProviderFound: 176 | return None 177 | return user_name 178 | 179 | 180 | def get_default_provider(): 181 | provider = get_config('provider.name') 182 | return provider or 'github' 183 | 184 | 185 | def getoutput(cmd, **kwargs): 186 | stdout, stderr = Popen( 187 | cmd, 188 | stdout=PIPE, 189 | stderr=open(os.devnull, 'wb'), 190 | universal_newlines=True, 191 | **kwargs 192 | ).communicate() 193 | return stdout[:-1] if stdout[-1:] == '\n' else stdout 194 | 195 | 196 | def get_branches(include_remotes=False): 197 | cmd = ['git', 'branch'] 198 | if include_remotes: 199 | cmd += ['--all'] 200 | 201 | return [x[2:].split()[0] for x in getoutput(cmd).splitlines()] 202 | 203 | 204 | def get_remote_repo_url(remote): 205 | from codecli.providers import get_git_service_provider 206 | 207 | return get_git_service_provider().get_remote_repo_url(remote) 208 | 209 | 210 | def get_remote_repo_name(remote): 211 | from codecli.providers import get_git_service_provider 212 | 213 | return get_git_service_provider().get_remote_repo_name(remote) 214 | 215 | 216 | def send_pullreq(head_repo, head_ref, base_repo, base_ref): 217 | from codecli.providers import get_git_service_provider 218 | 219 | return get_git_service_provider().send_pullreq( 220 | head_repo, head_ref, base_repo, base_ref 221 | ) 222 | 223 | 224 | def get_pullinfo(repo, pr_id): 225 | from codecli.providers import get_git_service_provider 226 | 227 | return get_git_service_provider().get_pullinfo(repo, pr_id) 228 | 229 | 230 | def browser_open(url): 231 | browser_name = get_config('webbrowser.name') 232 | if browser_name.lower() == 'none': 233 | return 234 | 235 | try: 236 | browser = webbrowser.get(browser_name or None) 237 | except webbrowser.Error: 238 | if browser_name: 239 | check_call([browser_name, url]) 240 | else: 241 | browser.open(url) 242 | 243 | 244 | def _wrap_with(code): 245 | def inner(text, bold=False): 246 | c = code 247 | if bold: 248 | c = "1;%s" % c 249 | return "\033[%sm%s\033[0m" % (c, text) 250 | 251 | return inner 252 | 253 | 254 | red = _wrap_with('31') 255 | green = _wrap_with('32') 256 | 257 | 258 | def is_under_git_repo(path=None): 259 | """Check if the given path is under a git repo""" 260 | return getoutput(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path) == 'true' 261 | -------------------------------------------------------------------------------- /images/codecli-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hongqn/codecli/2c33c525a6adca0f82f8e76df288d53a86dfc8bb/images/codecli-256.png -------------------------------------------------------------------------------- /images/codecli-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hongqn/codecli/2c33c525a6adca0f82f8e76df288d53a86dfc8bb/images/codecli-512.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | skip-string-normalization = true 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | usefixtures = input 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mock==2.0.0 2 | nose==1.3.7 3 | pbr==1.10.0 4 | pluggy==0.3.1 5 | py==1.10.0 6 | pytest==3.0.2 7 | six==1.10.0 8 | tox==2.3.1 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | # package meta info 5 | NAME = "CodeCLI" 6 | VERSION = "1.1.0" 7 | DESCRIPTION = "A command line tool for github flow collaboration" 8 | AUTHOR = "Qiangning Hong" 9 | AUTHOR_EMAIL = "hongqn@gmail.com" 10 | LICENSE = "BSD" 11 | URL = "" 12 | KEYWORDS = "" 13 | CLASSIFIERS = [] 14 | 15 | # package contents 16 | MODULES = [] 17 | PACKAGES = find_packages(exclude=['tests.*', 'tests', 'examples.*', 'examples']) 18 | ENTRY_POINTS = """ 19 | [console_scripts] 20 | code = codecli:main 21 | """ 22 | 23 | # dependencies 24 | INSTALL_REQUIRES = [ 25 | "six", 26 | ] 27 | SETUP_REQUIRES = [] 28 | TESTS_REQUIRE = [ 29 | 'nose', 30 | ] 31 | 32 | here = os.path.abspath(os.path.dirname(__file__)) 33 | 34 | 35 | def read_long_description(filename): 36 | path = os.path.join(here, filename) 37 | if os.path.exists(path): 38 | return open(path).read() 39 | return "" 40 | 41 | 42 | setup( 43 | name=NAME, 44 | version=VERSION, 45 | description=DESCRIPTION, 46 | long_description=read_long_description('README.rst'), 47 | author=AUTHOR, 48 | author_email=AUTHOR_EMAIL, 49 | license=LICENSE, 50 | url=URL, 51 | keywords=KEYWORDS, 52 | classifiers=CLASSIFIERS, 53 | py_modules=MODULES, 54 | packages=PACKAGES, 55 | install_package_data=True, 56 | zip_safe=False, 57 | entry_points=ENTRY_POINTS, 58 | install_requires=INSTALL_REQUIRES, 59 | setup_requires=SETUP_REQUIRES, 60 | tests_require=TESTS_REQUIRE, 61 | ) 62 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hongqn/codecli/2c33c525a6adca0f82f8e76df288d53a86dfc8bb/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | 5 | import codecli.utils 6 | 7 | 8 | @pytest.fixture 9 | def input(monkeypatch): 10 | def input(prompt, pattern=r'.*', default=''): 11 | return default 12 | 13 | monkeypatch.setattr(codecli.utils, 'input', input) 14 | -------------------------------------------------------------------------------- /tests/test_commands/test_clone.py: -------------------------------------------------------------------------------- 1 | from mock import patch, Mock 2 | import codecli.commands.clone as M 3 | 4 | 5 | def test_should_run_git_clone(): 6 | args = Mock(repo='repo', dir=None) 7 | with patch.object(M, 'check_call') as mock_check_call, patch.object( 8 | M, 'cd' 9 | ) as mock_cd: 10 | M.main(args) 11 | 12 | mock_cd.assert_called_with('repo') 13 | mock_check_call.assert_any_call( 14 | ['git', 'clone', 'http://code.dapps.douban.com/repo.git'] 15 | ) 16 | 17 | 18 | def test_should_run_git_clone_with_dir_when_dir_is_given(): 19 | args = Mock(repo='repo', dir='.') 20 | with patch.object(M, 'check_call') as mock_check_call, patch.object( 21 | M, 'cd' 22 | ) as mock_cd: 23 | M.main(args) 24 | 25 | mock_cd.assert_called_with('.') 26 | mock_check_call.assert_any_call( 27 | ['git', 'clone', 'http://code.dapps.douban.com/repo.git', '.'] 28 | ) 29 | -------------------------------------------------------------------------------- /tests/test_commands/test_fetch.py: -------------------------------------------------------------------------------- 1 | from mock import patch, Mock 2 | import codecli.commands.fetch as M 3 | 4 | 5 | def test_fetch_should_add_remote_and_fetch(): 6 | args = Mock(username='testuser') 7 | with patch.object(M, 'getoutput') as mock_getoutput, patch.object( 8 | M, 'check_call' 9 | ) as mock_check_call: 10 | mock_getoutput.return_value = """\ 11 | origin http://code.dapps.douban.com/codecli.git (fetch) 12 | origin http://code.dapps.douban.com/codecli.git (push) 13 | """ 14 | M.main(args) 15 | 16 | mock_check_call.assert_any_call( 17 | [ 18 | 'git', 19 | 'remote', 20 | 'add', 21 | 'testuser', 22 | 'http://code.dapps.douban.com/testuser/codecli.git', 23 | ] 24 | ) 25 | mock_check_call.assert_any_call(['git', 'fetch', 'testuser']) 26 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from subprocess import check_call 2 | from nose.tools import eq_ 3 | from mock import patch 4 | import pytest 5 | 6 | import codecli.utils as M 7 | from tests.utils import mkdtemp 8 | 9 | 10 | env = {} 11 | env['GIT_AUTHOR_NAME'] = 'anonymous' 12 | env['GIT_AUTHOR_EMAIL'] = 'anonymous@douban.com' 13 | env['GIT_COMMITTER_NAME'] = 'anonymous' 14 | env['GIT_COMMITTER_EMAIL'] = 'anonymous@douban.com' 15 | 16 | 17 | def test_get_branches_should_return_a_list_of_branch_names(): 18 | with mkdtemp(cd=True): 19 | check_call(['git', 'init']) 20 | open('test', 'w').close() 21 | check_call(['git', 'add', 'test']) 22 | check_call(['git', 'commit', '-m', 'test'], env=env) 23 | 24 | branches = M.get_branches() 25 | eq_(branches, ['master']) 26 | 27 | 28 | @pytest.fixture 29 | def in_git(): 30 | with patch.object(M, 'is_under_git_repo') as m: 31 | m.return_value = True 32 | yield 33 | 34 | 35 | def test_get_remote_repo_url_should_work(in_git): 36 | with patch.object(M, 'getoutput') as mock_getoutput: 37 | mock_getoutput.return_value = """\ 38 | origin http://code.dapps.douban.com/testrepo.git (fetch) 39 | origin http://code.dapps.douban.com/testrepo.git (push) 40 | upstream http://code.dapps.douban.com/testrepo.git (fetch) 41 | upstream http://code.dapps.douban.com/testrepo.git (push) 42 | """ 43 | 44 | repourl = M.get_remote_repo_url('origin') 45 | eq_(repourl, 'http://code.dapps.douban.com/testrepo') 46 | 47 | 48 | def test_get_remote_repo_url_should_work_with_user(): 49 | with patch.object(M, 'getoutput') as mock_getoutput: 50 | mock_getoutput.return_value = """\ 51 | origin http://Louis14@code.dapps.douban.com/testrepo.git (fetch) 52 | origin http://Louis14@code.dapps.douban.com/testrepo.git (push) 53 | upstream http://code.dapps.douban.com/testrepo.git (fetch) 54 | upstream http://code.dapps.douban.com/testrepo.git (push) 55 | """ 56 | 57 | repourl = M.get_remote_repo_url('origin') 58 | eq_(repourl, 'http://Louis14@code.dapps.douban.com/testrepo') 59 | 60 | 61 | def test_get_repo_name(): 62 | with patch.object(M, 'getoutput') as mock_getoutput: 63 | mock_getoutput.return_value = """\ 64 | origin http://code.dapps.douban.com/testrepo.git (fetch) 65 | origin http://code.dapps.douban.com/testrepo.git (push) 66 | upstream http://code.dapps.douban.com/testrepo.git (fetch) 67 | upstream http://code.dapps.douban.com/testrepo.git (push) 68 | """ 69 | n = M.get_remote_repo_name('origin') 70 | eq_(n, 'testrepo') 71 | 72 | 73 | def test_get_repo_name_with_user(): 74 | with patch.object(M, 'getoutput') as mock_getoutput: 75 | mock_getoutput.return_value = """\ 76 | origin http://Louis14@code.dapps.douban.com/testrepo.git (fetch) 77 | origin http://Louis14@code.dapps.douban.com/testrepo.git (push) 78 | upstream http://code.dapps.douban.com/testrepo.git (fetch) 79 | upstream http://code.dapps.douban.com/testrepo.git (push) 80 | """ 81 | n = M.get_remote_repo_name('origin') 82 | eq_(n, 'testrepo') 83 | 84 | 85 | def test_repo_git_url(): 86 | eq_(M.repo_git_url('shire'), 'http://code.dapps.douban.com/shire.git') 87 | eq_(M.repo_git_url('user/shire'), 'http://code.dapps.douban.com/user/shire.git') 88 | eq_( 89 | M.repo_git_url('user/shire', login_user='user'), 90 | 'http://user@code.dapps.douban.com/user/shire.git', 91 | ) 92 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import contextmanager 3 | import tempfile 4 | import shutil 5 | 6 | 7 | @contextmanager 8 | def mkdtemp(cd=False): 9 | dir = tempfile.mkdtemp() 10 | try: 11 | if cd: 12 | with chdir(dir): 13 | yield dir 14 | else: 15 | yield dir 16 | finally: 17 | shutil.rmtree(dir) 18 | 19 | 20 | @contextmanager 21 | def chdir(dir): 22 | cwd = os.getcwd() 23 | os.chdir(dir) 24 | try: 25 | yield dir 26 | finally: 27 | os.chdir(cwd) 28 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py35 3 | 4 | [testenv] 5 | deps = -rrequirements.txt 6 | commands = py.test 7 | 8 | [pep8] 9 | max-line-length = 99 10 | --------------------------------------------------------------------------------