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 scp but # performs the file operations using externally configured commands. # It is a single binary without any dependencies. # # It can also keep track of multiple remote areas and do up/downloads # independent from git. This is useful in build scripts, up and # downloading artifacts. # # Just run blimp --help for detailed help. const versionMajor* = 0 versionMinor* = 5 versionPatch* = 0 versionAsString* = $versionMajor & "." & $versionMinor & "." & $versionPatch type RemoteArea = ref object of RootObj name*: string url*: string upload*: string download*: string delete*: string var blimpStore, blimpVersion: string = nil homeDir, currentDir, gitRootDir: string verbose, stdio, onAllDeflated, onAllFiltered: bool stdinContent, area: string = nil areas = newTable[string, RemoteArea]() remoteArea: RemoteArea let defaultConfig = """ [blimp] # Minimal version, otherwise stop version = """ & versionAsString & """ # # 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" [remote] # The "remote" area represents the shared remote blimpstore. # This area is the default area used unless --area is used. url = "blimp@build.3dicc.com:/var/opt/blimpstore" # On Windows we rely on msysgit that includes scp. # On XP, make sure to put your ssh keys # in c:\Program Files\git\.ssh (slightly odd place) # On Win7 it uses c:\Users\gokr\.ssh - you can check with # "ssh -v somehost" to see where it looks for the keys. # # When expanding the command templates below: # $1 is the blimp filename # $2 is the area url above # $3 is the local blimpstore directory upload = "scp -pqC '$3/$1' '$2/'" download = "scp -pqC '$2/$1' '$3/'" [release] # This area can be used with upload/download commands and --area option. # -r makes it possible to give directories as arguments for recursive handling. url = "blimp@build.3dicc.com:/var/opt/release" upload = "scp -rC '$3/$1' '$2/'" download = "scp -rC '$2/$1' '$3/'" """ 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 # Various git commands proc gitRoot(): string = cmd("git rev-parse --show-toplevel") proc gitConfigSet(key, val: string) = discard cmd("git config " & $key & " " & $val) proc gitConfigUnset(key: string) = discard cmd("git config --unset " & $key) proc gitConfigGet(key: string): string = cmd("git config --get " & $key) # Ensure the filter is set proc ensureBlimpFilter() = if gitConfigGet("filter.blimp.clean") != "": gitConfigSet("filter.blimp.clean", "\"blimp -s d %f\"") gitConfigSet("filter.blimp.smudge", "\"blimp -s i %f\"") # Ensure the filter is set proc removeBlimpFilter() = if gitConfigGet("filter.blimp.clean") != "": gitConfigUnset("filter.blimp.clean") gitConfigUnset("filter.blimp.smudge") # 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) # Copy of readAllBuffer from sysio.nim - we need to call it # instead of readAll(stdin) which doesn't work on Windows. readAll # first tries to find file size, and that trips up Windows. proc readAllX(file: File): string = result = "" var buffer = newString(4096) while true: var read = readBuffer(file, addr(buffer[0]), 4096) if read == 4096: result.add(buffer) else: buffer.setLen(read) result.add(buffer) break # 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) var section: string var area: RemoteArea while true: var e = next(p) case e.kind of cfgEof: break of cfgSectionStart: section = e.section of cfgKeyValuePair: case section of "blimp": case e.key of "blimpstore": if blimpStore.isNil: blimpStore = expandDirs(e.value) of "version": if blimpVersion.isNil: blimpVersion = e.value else: quit("Unknown blimp configuration: " & e.key) else: # Then we presume its an area if area.isNil or area.name != section: area = RemoteArea(name: section) areas[area.name] = area case e.key of "url": if area.url.isNil: area.url = expandDirs(e.value) of "upload": if area.upload.isNil: area.upload = expandDirs(e.value) of "download": if area.download.isNil: area.download = expandDirs(e.value) of "delete": if area.delete.isNil: area.delete = expandDirs(e.value) else: quit("Unknown area 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): int = if verbose: echo(cmd) execCmd(cmd) # Perhaps not perfect but tries to convert c:\foo\bar to /c/foo/bar # but only if we are on windows, otherwise we do nothing. proc toUnixyPath(path: string): string = when defined(windows): if path.len > 1 and path[1] == ':': let parts = path.split({':', '\\'}) result = "/" & parts.join("/") else: result = path else: result = path # Upload a file to the remote area proc uploadFile(filename, fromDir: string) = if remoteArea.isNil: echo("Remote area not set in configuration file, skipping uploading content:\n\t" & filename) return let errorCode = run(format(remoteArea.upload, filename, remoteArea.url, toUnixyPath(fromDir))) if errorCode != 0: quit("Something went wrong uploading " & filename & " to " & remoteArea.url, 2) # Download a file to the remote area proc downloadFile(filename, toDir: string) = if remoteArea.isNil: quit("Remote area not set in configuration file, can not download content:\n\t" & filename) let errorCode = run(format(remoteArea.download, filename, remoteArea.url, toUnixyPath(toDir))) if errorCode != 0: quit("Something went wrong downloading " & filename & " from " & remoteArea.url, 3) # Delete a file from the remote master blimpStore proc remoteDeleteFile(filename: string) = if remoteArea.isNil: return let errorCode = run(format(remoteArea.delete, filename, remoteArea.url, toUnixyPath(blimpStore))) if errorCode != 0: quit("Something went wrong deleting " & filename & " from " & remoteArea.url, 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, blimpStore) # Copy content from blimpStore, and downloading first if needed proc copyFromBlimpStore(blimpFilename, filename: string) = if not existsFile(blimpStore / blimpFilename): downloadFile(blimpFilename, blimpStore) 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(): string = 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(): string = 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.") # Make sure a file already in blimpstore is uploaded to remote area. proc push(filename: string) = if verbose: echo "Uploading " & filename var blimpFilename = blimpFilename(filename) if blimpFilename.isNil: blimpFilename = computeBlimpFilename(filename) if existsFile(blimpStore / blimpFilename): uploadFile(blimpFilename, blimpStore) if verbose: echo("\t" & filename & " uploaded.") else: if verbose: echo("\t" & filename & " is not in blimpstore, skipping.") # Upload a file to a remote area proc upload(filename: string) = if verbose: echo "Uploading " & filename uploadFile(filename, currentDir) if verbose: echo("\t" & filename & " uploaded.") # Download a file from a remote area. proc download(filename: string) = if verbose: echo "Downloading " & filename downloadFile(filename, currentDir) if verbose: echo("\t" & filename & " downloaded.") 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 "\tremote-url: " & remoteArea.url echo "\tremote-upload: " & remoteArea.upload echo "\tremote-download: " & remoteArea.download 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 ---------- (string) (d)eflate, (i)nflate, init, deinit, remove, push, upload, download --all Operate on all deflated files in clone -f,--filter Operate on all files matching blimp filter -a,--area (default remote) The area to use for remote up/downloads ---------- -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 push command (no single character shortcut) will force 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 synced onto the remote. The upload and download commands are used to distribute artifacts typically in a build script. If no --area is given, they use the standard "remote" area. 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" just like git. 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 blimp inflate --filter This will configure the blimp filter and then 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() # 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 area = args["area"].asString # 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 = readAllX(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" # Check if we have a configured remoteArea if areas.hasKey(area): remoteArea = areas[area] # 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) # Ok, let's see 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 let command = args["command"].asString if command != "": # Should we install the filter? if command == "init": ensureBlimpFilter() echo("Installed blimp filter") quit(0) elif command == "deinit": removeBlimpFilter() echo("Removed blimp filter") quit(0) elif 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 == "push": for fn in filenames.items: push(fn) elif command == "upload": for fn in filenames.items: upload(fn) elif command == "download": for fn in filenames.items: download(fn) else: quit("Unknown command: \"" & command & "\", use --help for valid commands.", 6) # All good quit(0)