// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package dep

import (
	"context"
	"encoding/hex"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"

	"github.com/golang/dep/gps"
	"github.com/golang/dep/gps/verify"
	"github.com/golang/dep/internal/fs"
	"github.com/pkg/errors"
)

const (
	// Helper consts for common diff-checking patterns.
	anyExceptHash verify.DeltaDimension = verify.AnyChanged & ^verify.HashVersionChanged & ^verify.HashChanged
)

// Example string to be written to the manifest file
// if no dependencies are found in the project
// during `dep init`
var exampleTOML = []byte(`# Gopkg.toml example
#
# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
#   name = "github.com/user/project"
#   version = "1.0.0"
#
# [[constraint]]
#   name = "github.com/user/project2"
#   branch = "dev"
#   source = "github.com/myfork/project2"
#
# [[override]]
#   name = "github.com/x/y"
#   version = "2.4.0"
#
# [prune]
#   non-go = false
#   go-tests = true
#   unused-packages = true

`)

// String added on top of lock file
var lockFileComment = []byte(`# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.

`)

// SafeWriter transactionalizes writes of manifest, lock, and vendor dir, both
// individually and in any combination, into a pseudo-atomic action with
// transactional rollback.
//
// It is not impervious to errors (writing to disk is hard), but it should
// guard against non-arcane failure conditions.
type SafeWriter struct {
	Manifest     *Manifest
	lock         *Lock
	lockDiff     verify.LockDelta
	writeVendor  bool
	writeLock    bool
	pruneOptions gps.CascadingPruneOptions
}

// NewSafeWriter sets up a SafeWriter to write a set of manifest, lock, and
// vendor tree.
//
// - If manifest is provided, it will be written to the standard manifest file
// name beneath root.
//
// - If newLock is provided, it will be written to the standard lock file
// name beneath root.
//
// - If vendor is VendorAlways, or is VendorOnChanged and the locks are different,
// the vendor directory will be written beneath root based on newLock.
//
// - If oldLock is provided without newLock, error.
//
// - If vendor is VendorAlways without a newLock, error.
func NewSafeWriter(manifest *Manifest, oldLock, newLock *Lock, vendor VendorBehavior, prune gps.CascadingPruneOptions, status map[string]verify.VendorStatus) (*SafeWriter, error) {
	sw := &SafeWriter{
		Manifest:     manifest,
		lock:         newLock,
		pruneOptions: prune,
	}

	if oldLock != nil {
		if newLock == nil {
			return nil, errors.New("must provide newLock when oldLock is specified")
		}

		sw.lockDiff = verify.DiffLocks(oldLock, newLock)
		if sw.lockDiff.Changed(anyExceptHash) {
			sw.writeLock = true
		}
	} else if newLock != nil {
		sw.writeLock = true
	}

	switch vendor {
	case VendorAlways:
		sw.writeVendor = true
	case VendorOnChanged:
		if newLock != nil && oldLock == nil {
			sw.writeVendor = true
		} else if sw.lockDiff.Changed(anyExceptHash & ^verify.InputImportsChanged) {
			sw.writeVendor = true
		} else {
			for _, stat := range status {
				if stat != verify.NoMismatch {
					sw.writeVendor = true
					break
				}
			}
		}
	}

	if sw.writeVendor && newLock == nil {
		return nil, errors.New("must provide newLock in order to write out vendor")
	}

	return sw, nil
}

// HasLock checks if a Lock is present in the SafeWriter
func (sw *SafeWriter) HasLock() bool {
	return sw.lock != nil
}

// HasManifest checks if a Manifest is present in the SafeWriter
func (sw *SafeWriter) HasManifest() bool {
	return sw.Manifest != nil
}

// VendorBehavior defines when the vendor directory should be written.
type VendorBehavior int

const (
	// VendorOnChanged indicates that the vendor directory should be written
	// when the lock is new or changed, or a project in vendor differs from its
	// intended state.
	VendorOnChanged VendorBehavior = iota
	// VendorAlways forces the vendor directory to always be written.
	VendorAlways
	// VendorNever indicates the vendor directory should never be written.
	VendorNever
)

func (sw SafeWriter) validate(root string, sm gps.SourceManager) error {
	if root == "" {
		return errors.New("root path must be non-empty")
	}
	if is, err := fs.IsDir(root); !is {
		if err != nil && !os.IsNotExist(err) {
			return err
		}
		return errors.Errorf("root path %q does not exist", root)
	}

	if sw.writeVendor && sm == nil {
		return errors.New("must provide a SourceManager if writing out a vendor dir")
	}

	return nil
}

// Write saves some combination of manifest, lock, and a vendor tree. root is
// the absolute path of root dir in which to write. sm is only required if
// vendor is being written.
//
// It first writes to a temp dir, then moves them in place if and only if all
// the write operations succeeded. It also does its best to roll back if any
// moves fail. This mostly guarantees that dep cannot exit with a partial write
// that would leave an undefined state on disk.
//
// If logger is not nil, progress will be logged after each project write.
func (sw *SafeWriter) Write(root string, sm gps.SourceManager, examples bool, logger *log.Logger) error {
	err := sw.validate(root, sm)
	if err != nil {
		return err
	}

	if !sw.HasManifest() && !sw.writeLock && !sw.writeVendor {
		// nothing to do
		return nil
	}

	mpath := filepath.Join(root, ManifestName)
	lpath := filepath.Join(root, LockName)
	vpath := filepath.Join(root, "vendor")

	td, err := ioutil.TempDir(os.TempDir(), "dep")
	if err != nil {
		return errors.Wrap(err, "error while creating temp dir for writing manifest/lock/vendor")
	}
	defer os.RemoveAll(td)

	if sw.HasManifest() {
		// Always write the example text to the bottom of the TOML file.
		tb, err := sw.Manifest.MarshalTOML()
		if err != nil {
			return errors.Wrap(err, "failed to marshal manifest to TOML")
		}

		var initOutput []byte

		// If examples are enabled, use the example text
		if examples {
			initOutput = exampleTOML
		}

		if err = ioutil.WriteFile(filepath.Join(td, ManifestName), append(initOutput, tb...), 0666); err != nil {
			return errors.Wrap(err, "failed to write manifest file to temp dir")
		}
	}

	if sw.writeVendor {
		var onWrite func(gps.WriteProgress)
		if logger != nil {
			onWrite = func(progress gps.WriteProgress) {
				logger.Println(progress)
			}
		}
		err = gps.WriteDepTree(filepath.Join(td, "vendor"), sw.lock, sm, sw.pruneOptions, onWrite)
		if err != nil {
			return errors.Wrap(err, "error while writing out vendor tree")
		}

		for k, lp := range sw.lock.Projects() {
			vp := lp.(verify.VerifiableProject)
			vp.Digest, err = verify.DigestFromDirectory(filepath.Join(td, "vendor", string(lp.Ident().ProjectRoot)))
			if err != nil {
				return errors.Wrapf(err, "error while hashing tree of %s in vendor", lp.Ident().ProjectRoot)
			}
			sw.lock.P[k] = vp
		}
	}

	if sw.writeLock {
		l, err := sw.lock.MarshalTOML()
		if err != nil {
			return errors.Wrap(err, "failed to marshal lock to TOML")
		}

		if err = ioutil.WriteFile(filepath.Join(td, LockName), append(lockFileComment, l...), 0666); err != nil {
			return errors.Wrap(err, "failed to write lock file to temp dir")
		}
	}

	// Ensure vendor/.git is preserved if present
	if hasDotGit(vpath) {
		err = fs.RenameWithFallback(filepath.Join(vpath, ".git"), filepath.Join(td, "vendor/.git"))
		if _, ok := err.(*os.LinkError); ok {
			return errors.Wrap(err, "failed to preserve vendor/.git")
		}
	}

	// Move the existing files and dirs to the temp dir while we put the new
	// ones in, to provide insurance against errors for as long as possible.
	type pathpair struct {
		from, to string
	}
	var restore []pathpair
	var failerr error
	var vendorbak string

	if sw.HasManifest() {
		if _, err := os.Stat(mpath); err == nil {
			// Move out the old one.
			tmploc := filepath.Join(td, ManifestName+".orig")
			failerr = fs.RenameWithFallback(mpath, tmploc)
			if failerr != nil {
				goto fail
			}
			restore = append(restore, pathpair{from: tmploc, to: mpath})
		}

		// Move in the new one.
		failerr = fs.RenameWithFallback(filepath.Join(td, ManifestName), mpath)
		if failerr != nil {
			goto fail
		}
	}

	if sw.writeLock {
		if _, err := os.Stat(lpath); err == nil {
			// Move out the old one.
			tmploc := filepath.Join(td, LockName+".orig")

			failerr = fs.RenameWithFallback(lpath, tmploc)
			if failerr != nil {
				goto fail
			}
			restore = append(restore, pathpair{from: tmploc, to: lpath})
		}

		// Move in the new one.
		failerr = fs.RenameWithFallback(filepath.Join(td, LockName), lpath)
		if failerr != nil {
			goto fail
		}
	}

	if sw.writeVendor {
		if _, err := os.Stat(vpath); err == nil {
			// Move out the old vendor dir. just do it into an adjacent dir, to
			// try to mitigate the possibility of a pointless cross-filesystem
			// move with a temp directory.
			vendorbak = vpath + ".orig"
			if _, err := os.Stat(vendorbak); err == nil {
				// If the adjacent dir already exists, bite the bullet and move
				// to a proper tempdir.
				vendorbak = filepath.Join(td, ".vendor.orig")
			}

			failerr = fs.RenameWithFallback(vpath, vendorbak)
			if failerr != nil {
				goto fail
			}
			restore = append(restore, pathpair{from: vendorbak, to: vpath})
		}

		// Move in the new one.
		failerr = fs.RenameWithFallback(filepath.Join(td, "vendor"), vpath)
		if failerr != nil {
			goto fail
		}
	}

	// Renames all went smoothly. The deferred os.RemoveAll will get the temp
	// dir, but if we wrote vendor, we have to clean that up directly
	if sw.writeVendor {
		// Nothing we can really do about an error at this point, so ignore it
		os.RemoveAll(vendorbak)
	}

	return nil

fail:
	// If we failed at any point, move all the things back into place, then bail.
	for _, pair := range restore {
		// Nothing we can do on err here, as we're already in recovery mode.
		fs.RenameWithFallback(pair.from, pair.to)
	}
	return failerr
}

// PrintPreparedActions logs the actions a call to Write would perform.
func (sw *SafeWriter) PrintPreparedActions(output *log.Logger, verbose bool) error {
	if output == nil {
		output = log.New(ioutil.Discard, "", 0)
	}
	if sw.HasManifest() {
		if verbose {
			m, err := sw.Manifest.MarshalTOML()
			if err != nil {
				return errors.Wrap(err, "ensure DryRun cannot serialize manifest")
			}
			output.Printf("Would have written the following %s:\n%s\n", ManifestName, string(m))
		} else {
			output.Printf("Would have written %s.\n", ManifestName)
		}
	}

	if sw.writeLock {
		if verbose {
			l, err := sw.lock.MarshalTOML()
			if err != nil {
				return errors.Wrap(err, "ensure DryRun cannot serialize lock")
			}
			output.Printf("Would have written the following %s:\n%s\n", LockName, string(l))
		} else {
			output.Printf("Would have written %s.\n", LockName)
		}
	}

	if sw.writeVendor {
		if verbose {
			output.Printf("Would have written the following %d projects to the vendor directory:\n", len(sw.lock.Projects()))
			lps := sw.lock.Projects()
			for i, p := range lps {
				output.Printf("(%d/%d) %s@%s\n", i+1, len(lps), p.Ident(), p.Version())
			}
		} else {
			output.Printf("Would have written %d projects to the vendor directory.\n", len(sw.lock.Projects()))
		}
	}

	return nil
}

// hasDotGit checks if a given path has .git file or directory in it.
func hasDotGit(path string) bool {
	gitfilepath := filepath.Join(path, ".git")
	_, err := os.Stat(gitfilepath)
	return err == nil
}

// DeltaWriter manages batched writes to populate vendor/ and update Gopkg.lock.
// Its primary design goal is to minimize writes by only writing things that
// have changed.
type DeltaWriter struct {
	lock      *Lock
	lockDiff  verify.LockDelta
	vendorDir string
	changed   map[gps.ProjectRoot]changeType
	behavior  VendorBehavior
}

type changeType uint8

const (
	hashMismatch changeType = iota + 1
	hashVersionMismatch
	hashAbsent
	noVerify
	solveChanged
	pruneOptsChanged
	missingFromTree
	projectAdded
	projectRemoved
	pathPreserved
)

// NewDeltaWriter prepares a vendor writer that will construct a vendor
// directory by writing out only those projects that actually need to be written
// out - they have changed in some way, or they lack the necessary hash
// information to be verified.
func NewDeltaWriter(p *Project, newLock *Lock, behavior VendorBehavior) (TreeWriter, error) {
	dw := &DeltaWriter{
		lock:      newLock,
		vendorDir: filepath.Join(p.AbsRoot, "vendor"),
		changed:   make(map[gps.ProjectRoot]changeType),
		behavior:  behavior,
	}

	if newLock == nil {
		return nil, errors.New("must provide a non-nil newlock")
	}

	status, err := p.VerifyVendor()
	if err != nil {
		return nil, err
	}

	_, err = os.Stat(dw.vendorDir)
	if err != nil {
		if os.IsNotExist(err) {
			// Provided dir does not exist, so there's no disk contents to compare
			// against. Fall back to the old SafeWriter.
			return NewSafeWriter(nil, p.Lock, newLock, behavior, p.Manifest.PruneOptions, status)
		}
		return nil, err
	}

	dw.lockDiff = verify.DiffLocks(p.Lock, newLock)

	for pr, lpd := range dw.lockDiff.ProjectDeltas {
		// Hash changes aren't relevant at this point, as they could be empty
		// in the new lock, and therefore a symptom of a solver change.
		if lpd.Changed(anyExceptHash) {
			if lpd.WasAdded() {
				dw.changed[pr] = projectAdded
			} else if lpd.WasRemoved() {
				dw.changed[pr] = projectRemoved
			} else if lpd.PruneOptsChanged() {
				dw.changed[pr] = pruneOptsChanged
			} else {
				dw.changed[pr] = solveChanged
			}
		}
	}

	for spr, stat := range status {
		pr := gps.ProjectRoot(spr)
		// These cases only matter if there was no change already recorded via
		// the differ.
		if _, has := dw.changed[pr]; !has {
			switch stat {
			case verify.NotInTree:
				dw.changed[pr] = missingFromTree
			case verify.NotInLock:
				dw.changed[pr] = projectRemoved
			case verify.DigestMismatchInLock:
				dw.changed[pr] = hashMismatch
			case verify.HashVersionMismatch:
				dw.changed[pr] = hashVersionMismatch
			case verify.EmptyDigestInLock:
				dw.changed[pr] = hashAbsent
			}
		}
	}

	// Apply noverify last, as it should only supersede changeTypes with lower
	// values. It is NOT applied if no existing change is registered.
	for _, spr := range p.Manifest.NoVerify {
		pr := gps.ProjectRoot(spr)
		// We don't validate this field elsewhere as it can be difficult to know
		// at the beginning of a dep ensure command whether or not the noverify
		// project actually will exist as part of the Lock by the end of the
		// run. So, only apply if it's in the lockdiff.
		if _, has := dw.lockDiff.ProjectDeltas[pr]; has {
			if typ, has := dw.changed[pr]; has {
				if typ < noVerify {
					// Avoid writing noverify projects at all for the lower change
					// types.
					delete(dw.changed, pr)

					// Uncomment this if we want to switch to the safer behavior,
					// where we ALWAYS write noverify projects.
					//dw.changed[pr] = noVerify
				} else if typ == projectRemoved {
					// noverify can also be used to preserve files that would
					// otherwise be removed.
					dw.changed[pr] = pathPreserved
				}
			}
			// It's also allowed to preserve entirely unknown paths using noverify.
		} else if _, has := status[spr]; has {
			dw.changed[pr] = pathPreserved
		}
	}

	return dw, nil
}

// Write executes the planned changes.
//
// This writes recreated projects to a new directory, then moves in existing,
// unchanged projects from the original vendor directory. If any failures occur,
// reasonable attempts are made to roll back the changes.
func (dw *DeltaWriter) Write(path string, sm gps.SourceManager, examples bool, logger *log.Logger) error {
	// TODO(sdboyer) remove path from the signature for this
	if path != filepath.Dir(dw.vendorDir) {
		return errors.Errorf("target path (%q) must be the parent of the original vendor path (%q)", path, dw.vendorDir)
	}

	if logger == nil {
		logger = log.New(ioutil.Discard, "", 0)
	}

	lpath := filepath.Join(path, LockName)
	vpath := dw.vendorDir

	// Write the modified projects to a new adjacent directory. We use an
	// adjacent directory to minimize the possibility of cross-filesystem renames
	// becoming expensive copies, and to make removal of unneeded projects implicit
	// and automatic.
	vnewpath := filepath.Join(filepath.Dir(vpath), ".vendor-new")
	if _, err := os.Stat(vnewpath); err == nil {
		return errors.Errorf("scratch directory %s already exists, please remove it", vnewpath)
	}
	err := os.MkdirAll(vnewpath, os.FileMode(0777))
	if err != nil {
		return errors.Wrapf(err, "error while creating scratch directory at %s", vnewpath)
	}

	// Write out all the deltas to the newpath
	projs := make(map[gps.ProjectRoot]gps.LockedProject)
	for _, lp := range dw.lock.Projects() {
		projs[lp.Ident().ProjectRoot] = lp
	}

	var dropped, preserved []gps.ProjectRoot
	i := 0
	tot := len(dw.changed)
	for _, reason := range dw.changed {
		if reason != pathPreserved {
			logger.Println("# Bringing vendor into sync")
			break
		}
	}

	for pr, reason := range dw.changed {
		switch reason {
		case projectRemoved:
			dropped = append(dropped, pr)
			continue
		case pathPreserved:
			preserved = append(preserved, pr)
			continue
		}

		to := filepath.FromSlash(filepath.Join(vnewpath, string(pr)))
		po := projs[pr].(verify.VerifiableProject).PruneOpts
		if err := sm.ExportPrunedProject(context.TODO(), projs[pr], po, to); err != nil {
			return errors.Wrapf(err, "failed to export %s", pr)
		}

		i++
		lpd := dw.lockDiff.ProjectDeltas[pr]
		v, id := projs[pr].Version(), projs[pr].Ident()

		// Only print things if we're actually going to leave behind a new
		// vendor dir.
		if dw.behavior != VendorNever {
			logger.Printf("(%d/%d) Wrote %s@%s: %s", i, tot, id, v, changeExplanation(reason, lpd))
		}

		digest, err := verify.DigestFromDirectory(to)
		if err != nil {
			return errors.Wrapf(err, "failed to hash %s", pr)
		}

		// Update the new Lock with verification information.
		for k, lp := range dw.lock.P {
			if lp.Ident().ProjectRoot == pr {
				vp := lp.(verify.VerifiableProject)
				vp.Digest = digest
				dw.lock.P[k] = verify.VerifiableProject{
					LockedProject: lp,
					PruneOpts:     po,
					Digest:        digest,
				}
			}
		}
	}

	// Write out the lock, now that it's fully updated with digests.
	l, err := dw.lock.MarshalTOML()
	if err != nil {
		return errors.Wrap(err, "failed to marshal lock to TOML")
	}

	if err = ioutil.WriteFile(lpath, append(lockFileComment, l...), 0666); err != nil {
		return errors.Wrap(err, "failed to write new lock file")
	}

	if dw.behavior == VendorNever {
		return os.RemoveAll(vnewpath)
	}

	// Changed projects are fully populated. Now, iterate over the lock's
	// projects and move any remaining ones not in the changed list to vnewpath.
	for _, lp := range dw.lock.Projects() {
		pr := lp.Ident().ProjectRoot
		tgt := filepath.Join(vnewpath, string(pr))
		err := os.MkdirAll(filepath.Dir(tgt), os.FileMode(0777))
		if err != nil {
			return errors.Wrapf(err, "error creating parent directory in vendor for %s", tgt)
		}

		if _, has := dw.changed[pr]; !has {
			err = fs.RenameWithFallback(filepath.Join(vpath, string(pr)), tgt)
			if err != nil {
				return errors.Wrapf(err, "error moving unchanged project %s into scratch vendor dir", pr)
			}
		}
	}

	for i, pr := range dropped {
		// Kind of a lie to print this. ¯\_(ツ)_/¯
		fi, err := os.Stat(filepath.Join(vpath, string(pr)))
		if err != nil {
			return errors.Wrap(err, "could not stat file that VerifyVendor claimed existed")
		}

		if fi.IsDir() {
			logger.Printf("(%d/%d) Removed unused project %s", tot-(len(dropped)-i-1), tot, pr)
		} else {
			logger.Printf("(%d/%d) Removed orphaned file %s", tot-(len(dropped)-i-1), tot, pr)
		}
	}

	// Special case: ensure vendor/.git is preserved if present
	if hasDotGit(vpath) {
		preserved = append(preserved, ".git")
	}

	for _, path := range preserved {
		err = fs.RenameWithFallback(filepath.Join(vpath, string(path)), filepath.Join(vnewpath, string(path)))
		if err != nil {
			return errors.Wrapf(err, "failed to preserve vendor/%s", path)
		}
	}

	err = os.RemoveAll(vpath)
	if err != nil {
		return errors.Wrap(err, "failed to remove original vendor directory")
	}
	err = fs.RenameWithFallback(vnewpath, vpath)
	if err != nil {
		return errors.Wrap(err, "failed to put new vendor directory into place")
	}

	return nil
}

// changeExplanation outputs a string explaining what changed for each different
// possible changeType.
func changeExplanation(c changeType, lpd verify.LockedProjectDelta) string {
	switch c {
	case noVerify:
		return "verification is disabled"
	case solveChanged:
		if lpd.SourceChanged() {
			return fmt.Sprintf("source changed (%s -> %s)", lpd.SourceBefore, lpd.SourceAfter)
		} else if lpd.VersionChanged() {
			if lpd.VersionBefore == nil {
				return fmt.Sprintf("version changed (was a bare revision)")
			}
			return fmt.Sprintf("version changed (was %s)", lpd.VersionBefore.String())
		} else if lpd.RevisionChanged() {
			return fmt.Sprintf("revision changed (%s -> %s)", trimSHA(lpd.RevisionBefore), trimSHA(lpd.RevisionAfter))
		} else if lpd.PackagesChanged() {
			la, lr := len(lpd.PackagesAdded), len(lpd.PackagesRemoved)
			if la > 0 && lr > 0 {
				return fmt.Sprintf("packages changed (%v added, %v removed)", la, lr)
			} else if la > 0 {
				return fmt.Sprintf("packages changed (%v added)", la)
			}
			return fmt.Sprintf("packages changed (%v removed)", lr)
		}
	case pruneOptsChanged:
		// Override what's on the lockdiff with the extra info we have;
		// this lets us excise PruneNestedVendorDirs and get the real
		// value from the input param in place.
		old := lpd.PruneOptsBefore & ^gps.PruneNestedVendorDirs
		new := lpd.PruneOptsAfter & ^gps.PruneNestedVendorDirs
		return fmt.Sprintf("prune options changed (%s -> %s)", old, new)
	case hashMismatch:
		return "hash of vendored tree didn't match digest in Gopkg.lock"
	case hashVersionMismatch:
		return "hashing algorithm mismatch"
	case hashAbsent:
		return "hash digest absent from lock"
	case projectAdded:
		return "new project"
	case missingFromTree:
		return "missing from vendor"
	default:
		panic(fmt.Sprintf("unrecognized changeType value %v", c))
	}

	return ""
}

// PrintPreparedActions indicates what changes the DeltaWriter plans to make.
func (dw *DeltaWriter) PrintPreparedActions(output *log.Logger, verbose bool) error {
	if verbose {
		l, err := dw.lock.MarshalTOML()
		if err != nil {
			return errors.Wrap(err, "ensure DryRun cannot serialize lock")
		}
		output.Printf("Would have written the following %s (hash digests may be incorrect):\n%s\n", LockName, string(l))
	} else {
		output.Printf("Would have written %s.\n", LockName)
	}

	projs := make(map[gps.ProjectRoot]gps.LockedProject)
	for _, lp := range dw.lock.Projects() {
		projs[lp.Ident().ProjectRoot] = lp
	}

	tot := len(dw.changed)
	if tot > 0 {
		output.Print("Would have updated the following projects in the vendor directory:\n\n")
		i := 0
		for pr, reason := range dw.changed {
			lpd := dw.lockDiff.ProjectDeltas[pr]
			if reason == projectRemoved {
				output.Printf("(%d/%d) Would have removed %s", i, tot, pr)
			} else {
				output.Printf("(%d/%d) Would have written %s@%s: %s", i, tot, projs[pr].Ident(), projs[pr].Version(), changeExplanation(reason, lpd))
			}
		}
	}

	return nil
}

// A TreeWriter is responsible for writing important dep states to disk -
// Gopkg.lock, vendor, and possibly Gopkg.toml.
type TreeWriter interface {
	PrintPreparedActions(output *log.Logger, verbose bool) error
	Write(path string, sm gps.SourceManager, examples bool, logger *log.Logger) error
}

// trimSHA checks if revision is a valid SHA1 digest and trims to 10 characters.
func trimSHA(revision gps.Revision) string {
	if len(revision) == 40 {
		if _, err := hex.DecodeString(string(revision)); err == nil {
			// Valid SHA1 digest
			revision = revision[0:10]
		}
	}

	return string(revision)
}
