import md5, os, osproc, parseopt2, strutils, parsecfg, streams, lapp, subexes, tables, sets # blimp is a little utility program for handling large files # in git repositories. Its inspired by git-fat and s3annex # but doesn't rely on S3 for storage - it uses rsync like git-fat. # It is a single binary without any dependencies. # # Just run blimp --help for detailed help. const versionMajor* = 0 versionMinor* = 3 versionPatch* = 0 versionAsString* = $versionMajor & "." & $versionMinor & "." & $versionPatch var blimpStore, remoteBlimpStore, uploadCommandFormat, downloadCommandFormat, deleteCommandFormat, rsyncPassword, blimpVersion: string = nil homeDir, currentDir, gitRootDir: string verbose, stdio, onAllDeflated, onAllFiltered: bool stdinContent: string = nil let defaultConfig = """ [rsync] # Set your local blimpstore directory. You can use %home%, %cwd% and %gitroot% in paths, works cross platform. # Example: # # Place it inside the git clone # blimpstore = "%gitroot%/.git/blimpstore" # # Place it in current working directory (not very useful) # blimpstore = "%cwd%/blimpstore" # # Default: # # Place it in the users home directory # blimpstore = "%home%/blimpstore" # Set this to your remote rsync location remote = "blimpuser@some-rsync-server.com::blimpstore" # Set this to your rsync password, it will be written out as a password-file called .blimp.pass on every rsync. password = "some-good-rsync-password-for-blimpuser" # The following three formats should not need editing. # $1 is the blimp filename, $2 is remote location and $3 is the local blimpstore directory set above. # NOTE: The password-file .blimp.pass will be created by blimp on every command, do not remove that option. upload = "rsync --password-file $3/.blimp.pass -azP $3/$1 $2/" download = "rsync --password-file $3/.blimp.pass -azP $2/$1 $3/" # This deletes a single file from destination, that is already deleted in source. Yeah... insane! But it works. delete = "rsync --password-file $3/.blimp.pass -d --delete --existing --ignore-existing --include '$1' --exclude '*' $3/ $2" [blimp] # Minimal version, otherwise stop # version = """ & versionAsString proc cmd(cmd: string): string = try: # Otherwise pipes will not work for git commands etc when defined(windows): let tup = execCmdEx("cmd /c \"" & cmd & "\"") else: let tup = execCmdEx(cmd) #echo "cmd: " & $cmd & "err:" & $tup[1] if tup[1] == 0: result = strip(tup[0]) else: result = nil except: result = nil # Find git root dir or nil proc gitRoot(): string = cmd("git rev-parse --show-toplevel") # Git config proc gitConfigSet(key, val: string) = discard cmd("git config " & $key & " " & $val) proc gitConfigGet(key: string): string = cmd("git config --get " & $key) # Set blimp filter proc setBlimpFilter() = gitConfigSet("filter.blimp.clean", "\"blimp -s d %f\"") gitConfigSet("filter.blimp.smudge", "\"blimp -s i %f\"") # Ensure the filter is set proc ensureBlimpFilter() = if gitConfigGet("filter.blimp.clean") != "": setBlimpFilter() # Simple expansion of %home%, %cwd% and %gitroot% proc expandDirs(templ: string): string = result = templ.replace("%home%", homeDir) result = result.replace("%cwd%", currentDir) if result.contains("%gitroot%"): if gitRootDir.isNil: quit("Not in a git clone, can not expand %gitroot% in '" & templ & "'") result = result.replace("%gitroot%", gitRootDir) # Load a blimp.conf file proc parseConfFile(filename: string) = var f = newFileStream(filename, fmRead) if f != nil: if verbose: echo "Reading config: " & filename var p: CfgParser open(p, f, filename) while true: var e = next(p) case e.kind of cfgEof: break of cfgSectionStart: continue # Ignore of cfgKeyValuePair: case e.key of "blimpstore": if blimpStore.isNil: blimpStore = expandDirs(e.value) of "remote": if remoteBlimpStore.isNil: remoteBlimpStore = expandDirs(e.value) of "password": if rsyncPassword.isNil: rsyncPassword = e.value of "upload": if uploadCommandFormat.isNil: uploadCommandFormat = expandDirs(e.value) of "download": if downloadCommandFormat.isNil: downloadCommandFormat = expandDirs(e.value) of "delete": if deleteCommandFormat.isNil: deleteCommandFormat = expandDirs(e.value) of "version": if blimpVersion.isNil: blimpVersion = e.value else: quit("Unknown configuration: " & e.key) of cfgOption: quit("Unknown configuration: " & e.key) of cfgError: quit("Parsing " & filename & ": " & e.msg) close(p) # Trivial helper to enable verbose proc run(cmd: string): auto = if verbose: echo(cmd) execCmd(cmd) # Every rsync command, make sure we have a password file proc rsyncRun(cmd: string): auto = if not rsyncPassword.isNil: writeFile(blimpStore / ".blimp.pass", rsyncPassword) if execCmd("chmod 600 \"" & blimpStore / ".blimp.pass\"") != 0: quit("Failed to chmod 600 " & blimpStore / ".blimp.pass") run(cmd) # Upload a file to the remote master blimpStore proc uploadFile(blimpFilename: string) = if remoteBlimpStore.isNil: echo("Remote blimpstore not set in configuration file, skipping uploading content:\n\t" & blimpFilename) return let errorCode = rsyncRun(format(uploadCommandFormat, blimpFilename, remoteBlimpStore, blimpStore)) if errorCode != 0: quit("Something went wrong uploading " & blimpFilename & " to " & remoteBlimpStore, 2) # Download a file to the remote master blimpStore proc downloadFile(blimpFilename: string) = if remoteBlimpStore.isNil: quit("Remote blimpstore not set in configuration file, can not download content:\n\t" & blimpFilename) let errorCode = rsyncRun(format(downloadCommandFormat, blimpFilename, remoteBlimpStore, blimpStore)) if errorCode != 0: quit("Something went wrong downloading " & blimpFilename & " from " & remoteBlimpStore, 3) # Delete a file from the remote master blimpStore proc remoteDeleteFile(blimpFilename: string) = if remoteBlimpStore.isNil: return let errorCode = rsyncRun(format(deleteCommandFormat, blimpFilename, remoteBlimpStore, blimpStore)) if errorCode != 0: quit("Something went wrong deleting " & blimpFilename & " from " & remoteBlimpStore, 3) # Copy content to blimpStore and upload if it was a new file or upload == true. proc copyToBlimpStore(filename, blimpFilename: string) = if not existsFile(blimpStore / blimpFilename): if stdio: try: writeFile(blimpStore / blimpFilename, stdinContent) except: quit("Failed writing file: " & blimpStore / blimpFilename & " from stdin", 1) else: copyFile(filename, blimpStore / blimpFilename) uploadFile(blimpFilename) # Copy content from blimpStore, and downloading first if needed proc copyFromBlimpStore(blimpFilename, filename: string) = if not existsFile(blimpStore / blimpFilename): downloadFile(blimpFilename) if stdio: try: var content = readFile(blimpStore / blimpFilename) write(stdout, content) except: quit("Failed reading file: " & blimpStore / blimpFilename & " to stdout", 1) else: copyFile(blimpStore / blimpFilename, filename) # Delete from blimpStore and remote. proc deleteFromBlimpStore(blimpFilename, filename: string) = if existsFile(blimpStore / blimpFilename): removeFile(blimpStore / blimpFilename) remoteDeleteFile(blimpFilename) proc blimpFileNameFromString(line: string): string = let hashline = split(strip(line), ':') if hashline[0] == "blimphash": result = hashline[1] else: result = nil # Pick out blimpFilename (filename & "-" & hash) or nil proc blimpFileName(filename: string): string = if stdio: blimpFileNameFromString(stdinContent) else: var hashfile: File if not open(hashfile, filename): quit("Failed opening file: " & filename, 4) blimpFileNameFromString(string(readLine(hashfile))) # Get hash and compute blimpFilename proc computeBlimpFilename(filename: string): string = var content: string try: content = readFile(filename) except: quit("Failed opening file: " & filename, 1) let hash = getMD5(content) result = extractFilename(filename) & "-" & hash # Copy original file to blimpStore and replace with hash stub in git. proc deflate(filename: string) = if verbose: echo "Deflating " & filename var blimpFilename = blimpFilename(filename) if not blimpFilename.isNil: echo("\t" & filename & " is already deflated, skipping.") else: blimpFilename = computeBlimpFilename(filename) copyToBlimpStore(filename, blimpFilename) if stdio: write(stdout, "blimphash:" & blimpFilename) else: writeFile(filename, "blimphash:" & blimpFilename) if verbose: echo("\t" & filename & " deflated.") # Iterator over all deflated files in the git clone iterator allDeflated() = let filenames = cmd("git ls-files " & gitRootDir).split({'\l', '\c'}) for fn in filenames: if not blimpFilename(fn).isNil: yield fn # Iterator over all files matching the blimp filter in the git clone iterator allFiltered() = let lines = cmd("git ls-files | git check-attr --stdin filter").split({'\l', '\c'}) for line in lines: let status = line.split(':') if strip(status[2]) == "blimp": yield status[0] # Parse out hash from hash stub and copy back original content from blimpStore. proc inflate(filename: string) = if verbose: echo "Inflating " & filename let blimpFilename = blimpFilename(filename) if blimpFilename.isNil: echo("\t" & filename & " is not deflated, skipping.") else: copyFromBlimpStore(blimpfilename, filename) if verbose: echo("\t" & filename & " inflated.") # Inflates file first (if deflated) and then removes current content for it, # both locally and in remote. proc remove(filename: string) = var blimpFilename = blimpFilename(filename) if not blimpFilename.isNil: copyFromBlimpStore(blimpfilename, filename) else: blimpFilename = computeBlimpFilename(filename) deleteFromBlimpStore(blimpfilename, filename) echo("\t" & filename & " content removed from blimpstore locally and remotely.") # Copy original file to blimpStore and replace with hash stub in git. proc upload(filename: string) = if verbose: echo "Uploading " & filename var blimpFilename = blimpFilename(filename) if blimpFilename.isNil: blimpFilename = computeBlimpFilename(filename) if existsFile(blimpStore / blimpFilename): uploadFile(blimpFilename) if verbose: echo("\t" & filename & " uploaded.") else: if verbose: echo("\t" & filename & " is not in blimpstore, skipping.") proc setupBlimpStore() = try: if not existsDir(blimpStore): createDir(blimpStore) except: quit("Could not create " & blimpStore & " directory.", 1) try: if not existsFile(blimpStore / ".blimp.conf"): writeFile(blimpStore / ".blimp.conf", defaultConfig) except: quit("Could not create .blimp.conf config file in " & blimpStore & " directory.", 1) proc `$`(x: string): string = if x.isNil: "nil" else: x proc dumpConfig() = echo "\nDump of configuration:" echo "\tblimpStore: " & $blimpStore echo "\tremoteBlimpStore: " & $remoteBlimpStore echo "\tuploadCommandFormat: " & $uploadCommandFormat echo "\tdownloadCommandFormat: " & $downloadCommandFormat echo "\tdeleteCommandFormat: " & $deleteCommandFormat echo "\trsyncPassword: " & $rsyncPassword echo "\tblimpVersion: " & $blimpVersion echo "\n" let synopsis = """ blimp [options] -h,--help Show this --version Show version of blimp -v,--verbose Verbosity, only works without -s -i,--init Set blimp filter in git config ---------- (string) (d)eflate, (i)nflate, remove, upload -a,--all Operate on all deflated files in clone -f,--filter Operate on all files matching blimp filter ---------- -s,--stdio If given, use stdin/stdout for content. (string...) One or more filepaths to inflate/deflate """ let help = """ blimp is a little utility program for handling large files in git repositories. Its inspired by git-fat and s3annex but doesn't rely on S3 for storage - it uses rsync like git-fat. It is a single binary without any dependencies. Its not as advanced as git-fat but basically does the same thing. Manual use: Use "blimp d mybigfile" to deflate a file, typically before commit. Use "blimp i mybigfile" to inflate it back to original content. When deflated the file only has this content: "blimphash:" "-" Deflate also copies the real content to your local blimpstore: /- ...and if configured also uploads it to "remote", using rsync. Configuration is in these locations in order: ./.blimp.conf /.blimp.conf ~//.blimp.conf ~/.blimp.conf This way you can have settings per directory, per git clone, per store and per user. A default blimpstore with a commented .blimp.conf is created in ~/blimpstore if you run blimp and no .blimp.conf is found. Edit ~/blimpstore/.blimp.conf (or in another location) and set a proper remote and the proper rsync password. This ensures that its also properly synced with a master rsync repository that is typically shared. Inflate will bring back the original content by copying from your local blimpstore, and if its not there, first download from the remote. Use this whenever you need to work/edit the big file - in order to get its real content. The filenames given are all processed. If -s is used content is processed via stdin/stdout and only one filename can be passed. This is used when running blimp via a git filter (smudge/clean), see below. The remove command (no single character shortcut) will remove the file(s) content both from the local blimpstore and from the remote. This only removes the current content version, not older versions. The file itself is first inflated, if needed, and not deleted. This only "unblimps" the file. The upload command (no single character shortcut) will upload the given file from the local blimpstore to the remote. This is normally done automatically, but this way you can make sure they are up on the remote. In order to have blimp work automatically you can: * Create a .gitattributes file with lines like: *.png filter=blimp binary * Configure blimp as a filter by running: git config filter.blimp.clean "blimp -s d %f" git config filter.blimp.smudge "blimp -s i %f" The above git config can be done by running "blimp --init". When the above is done (per clone) git will automatically run blimp deflate just before committing and blimp inflate when operations are done. This means that if you clone a git repository that already has a .gitattributes file in it that uses the blimp filter, then you should do: blimp --init inflate --filter This will configure the blimp filter and also find and inflate all deflated files throughout the clone. """ ################################ main ##################################### # Set some dirs homeDir = getHomeDir() homeDir = homeDir[0.. -2] # Not sure why it keeps a trailing "/" on Linux currentDir = getCurrentDir() gitRootDir = gitRoot() echo gitRootDir # Using lapp to get args, on parsing failure this will show usage automatically var args = parse(synopsis) verbose = args["verbose"].asBool stdio = args["stdio"].asBool onAllDeflated = args["all"].asBool onAllFiltered = args["filter"].asBool # Can't do verbose with -s, that messes up stdout, # read in all of stdin once and for all if stdio: verbose = false try: stdinContent = readAll(stdin) close(stdin) except: quit("Failed reading stdin", 1) # Parse configuration files, may shadow and override each other parseConfFile(currentDir / ".blimp.conf") if not gitRootDir.isNil and gitRootDir != currentDir: parseConfFile(gitRootDir / ".blimp.conf") if existsDir(homeDir / "blimpstore"): parseConfFile(homeDir / "blimpstore" / ".blimp.conf") parseConfFile(homeDir / ".blimp.conf") # If we haven't gotten a blimpstore yet, we set a default one if blimpStore.isNil: blimpStore = homeDir / "blimpstore" # Let's just show what we have :) if verbose: dumpConfig() # These two are special, they short out if args.showHelp: quit(synopsis & help) if args.showVersion: quit("blimp version: " & versionAsString) # Check blimpVersion if not blimpVersion.isNil and blimpVersion != versionAsString: quit("Wrong version of blimp, configuration wants: " & blimpVersion) # Should we install the filter? if args["init"].asBool: ensureBlimpFilter() if verbose: echo("Installed blimp filter") let command = args["command"].asString var filenames = initSet[string]() # Add upp all files to operate on in a Set if args.hasKey("filenames"): for f in args["filenames"].asSeq: if f.asString != "": filenames.incl(f.asString) if onAllDeflated: for fn in allDeflated(): filenames.incl(fn) if onAllFiltered: for fn in allFiltered(): filenames.incl(fn) # Make sure the local blimpstore is setup. setupBlimpStore() # Do the deed if command != "": if command == "d" or command == "deflate": for fn in filenames: deflate(fn) elif command == "i" or command == "inflate": for fn in filenames: inflate(fn) elif command == "remove": for fn in filenames: remove(fn) elif command == "upload": echo repr(filenames) for fn in filenames.items: upload(fn) else: quit("Unknown command: \"" & command & "\", only (d)eflate, (i)inflate, remove or upload are valid.", 6) # All good quit(0)